diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index eb739abd536d80619a4115c151e73a6aaed2d3f1..ecc7a64f0df995ae4b34c62f1ded4f6ea4bf9eae 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -742,6 +742,7 @@ class DjangoMessagesAdapter(AdapterBase):
         MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated',
         MESSAGES.BATCH_TRANSITION: 'batch_transition',
         MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations',
+        MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document "{title}"',
     }
 
     def batch_reviewers_updated(self, added, sources, **kwargs):
diff --git a/opentech/apply/activity/migrations/0037_add_upload_document.py b/opentech/apply/activity/migrations/0037_add_upload_document.py
new file mode 100644
index 0000000000000000000000000000000000000000..6220a12883f325980e280180b82d442270229fdb
--- /dev/null
+++ b/opentech/apply/activity/migrations/0037_add_upload_document.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-08 10:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0036_add_reject_project'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REJECT_PROJECT', 'Project was Rejected'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0038_auto_20190808_1142.py b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py
new file mode 100644
index 0000000000000000000000000000000000000000..47109db845f978f04c7889ca37c1e290ea0f4c15
--- /dev/null
+++ b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-08 10:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0037_add_upload_document'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='type',
+            field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index 6300607c6dc5d672db4a5be39ba70988028935a1..5780d17cf685852e4f7b5cf22c6fd39a6f641a36 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -32,6 +32,7 @@ class MESSAGES(Enum):
     SEND_FOR_APPROVAL = 'Send for Approval'
     APPROVE_PROJECT = 'Project was Approved'
     REQUEST_PROJECT_CHANGE = 'Project change requested'
+    UPLOAD_DOCUMENT = 'Document was Uploaded to Project'
 
     @classmethod
     def choices(cls):
diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
index c6f0746dc01080d2b61ae9874c63155cb935f8c0..fddbc6ec6cd742d63332a7842300f21c581eb196 100644
--- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
+++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
@@ -1,5 +1,5 @@
 {% load util_tags %}
-<form class="form {{extra_classes}}" method="post" id="{{ form.name }}">
+<form class="form {{extra_classes}}" method="post" id="{{ form.name }}" enctype="multipart/form-data">
     {% csrf_token %}
     <div class="form__item">
         {{ form }}
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index 0599a29d7b0873f6666f9e0fcaa13472fb93efd6..4fdf55a1935e62832a0de6661572f6d0a47d2506 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -5,7 +5,7 @@ from addressfield.fields import AddressField
 from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.users.groups import STAFF_GROUP_NAME
 
-from .models import COMMITTED, Approval, Project
+from .models import COMMITTED, Approval, PacketFile, Project
 
 
 class CreateProjectForm(forms.Form):
@@ -96,6 +96,16 @@ class SetPendingForm(forms.ModelForm):
         return super().save(*args, **kwargs)
 
 
+class UploadDocumentForm(forms.ModelForm):
+    class Meta:
+        fields = ['title', 'category', 'document']
+        model = PacketFile
+        widgets = {'title': forms.TextInput()}
+
+    def __init__(self, user=None, instance=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
 class UpdateProjectLeadForm(forms.ModelForm):
     class Meta:
         fields = ['lead']
diff --git a/opentech/apply/projects/migrations/0011_add_packet_file.py b/opentech/apply/projects/migrations/0011_add_packet_file.py
new file mode 100644
index 0000000000000000000000000000000000000000..bcf80d0576d0dbf8b2d00d10667c3c03f0e3ba8b
--- /dev/null
+++ b/opentech/apply/projects/migrations/0011_add_packet_file.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.0.13 on 2019-08-07 15:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0010_add_related_names_to_approval_fks'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PacketFile',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.TextField()),
+                ('document', models.FileField(upload_to=opentech.apply.projects.models.document_path)),
+                ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.DocumentCategory')),
+                ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.Project')),
+            ],
+        ),
+    ]
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index e83ca544085f5512aac8e6d77c8c2c42c88c9744..e2055aa7b1e8d946e341a5a7733e4ed9d61c17ac 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -1,3 +1,4 @@
+import collections
 import decimal
 
 from django.conf import settings
@@ -22,6 +23,21 @@ class Approval(models.Model):
         return f'Approval of "{self.project.title}" by {self.by}'
 
 
+def document_path(instance, filename):
+    return f'projects/{instance.project_id}/supporting_documents/{filename}'
+
+
+class PacketFile(models.Model):
+    category = models.ForeignKey("DocumentCategory", null=True, on_delete=models.CASCADE, related_name="packet_files")
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="packet_files")
+
+    title = models.TextField()
+    document = models.FileField(upload_to=document_path)
+
+    def __str__(self):
+        return f'Project file: {self.title}'
+
+
 COMMITTED = 'committed'
 CONTRACTING = 'contracting'
 PROJECT_STATUS_CHOICES = [
@@ -129,6 +145,24 @@ class Project(models.Model):
         correct_state = self.status == COMMITTED and not self.is_locked
         return correct_state and self.user_has_updated_details
 
+    def get_missing_document_categories(self):
+        """
+        Get the number of documents required to meet each DocumentCategorys minimum
+        """
+        # Count the number of documents in each category currently
+        existing_categories = DocumentCategory.objects.filter(packet_files__project=self)
+        counter = collections.Counter(existing_categories)
+
+        # Find the difference between the current count and recommended count
+        for category in DocumentCategory.objects.all():
+            current_count = counter[category]
+            difference = category.recommended_minimum - current_count
+            if difference > 0:
+                yield {
+                    'category': category,
+                    'difference': difference,
+                }
+
 
 class DocumentCategory(models.Model):
     name = models.CharField(max_length=254)
diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
index 80ed895b7f05020eb5c5735af9b70ad8a52bf6c9..bb52d2879a3e2cf1dfbf16fe7de0f50810d15663 100644
--- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
+++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
@@ -56,36 +56,30 @@
                     Every project should include the following documents:
                 </p>
                 <ul>
-                    {% for category in remaining_document_categories %}
-                        <li>{{ category.name }} ({{ category.recommended_minimum }})</li>
+                    {% for missing in remaining_document_categories %}
+                        <li>{{ missing.category.name }} ({{ missing.difference }})</li>
                     {% endfor %}
                 </ul>
             </div>
             {% endif %}
-            <!-- Example document list
-                <ul class="docs-block__document-list">
-                    <li class="docs-block__document">
-                        <div class="docs-block__document-inner">
-                            <p class="docs-block__document-info"><b>This is a document title</b></p>
-                            <p class="docs-block__document-info">This is a document type</p>
-                        </div>
-                        <div class="docs-block__document-inner">
-                            <a class="docs-block__document-link" href="#">Download</a>
-                            <a class="docs-block__document-link" href="#">Remove</a>
-                        </div>
-                    </li>
-                    <li class="docs-block__document">
-                        <div class="docs-block__document-inner">
-                            <p class="docs-block__document-info"><b>This is a document title</b></p>
-                            <p class="docs-block__document-info">This is a document type</p>
-                        </div>
-                        <div class="docs-block__document-inner">
-                            <a class="docs-block__document-link" href="#">Download</a>
-                            <a class="docs-block__document-link" href="#">Remove</a>
-                        </div>
-                    </li>
-                </ul>
-            -->
+
+            {% if object.packet_files.exists %}
+            <ul class="docs-block__document-list">
+                {% for document in object.packet_files.all %}
+                <li class="docs-block__document">
+                    <div class="docs-block__document-inner">
+                        <p class="docs-block__document-info"><b>{{ document.title }}</b></p>
+                        <p class="docs-block__document-info">{{ document.category.name }}</p>
+                    </div>
+                    <div class="docs-block__document-inner">
+                        <a class="docs-block__document-link" href="{{ document.document.url }}">Download</a>
+                        <a class="docs-block__document-link" href="#">Remove</a>
+                    </div>
+                </li>
+                {% endfor %}
+            </ul>
+            {% endif %}
+
         </li>
     </ul>
     <div class="docs-block__buttons">
@@ -103,12 +97,5 @@
 
 <div class="modal" id="upload-supporting-doc">
     <h4 class="modal__header-bar">Upload a new document</h4>
-    <div class="wrapper--outer-space-medium">
-        <label for="upload-document">Choose a document to upload:</label>
-        <input type="file" id="upload-document">
-    </div>
-    <div class="modal__buttons">
-        <button class="button button--primary button--submit" type="submit" name="submit">Upload</button>
-        <button data-fancybox-close class="button button--submit button--white">Cancel</button>
-    </div>
+    {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%}
 </div>
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index 67a5a88694dc40b997fb706f6e1a13442d7b13df..efea3ef464b886fac564a3fc26fbb1c1ccc97db6 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -5,7 +5,8 @@ import factory
 from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
-from opentech.apply.projects.models import Project
+from opentech.apply.projects.models import (DocumentCategory, PacketFile,
+                                            Project)
 from opentech.apply.users.tests.factories import UserFactory
 
 ADDRESS = {
@@ -34,6 +35,14 @@ def address_to_form_data():
     }
 
 
+class DocumentCategoryFactory(factory.DjangoModelFactory):
+    name = factory.Sequence('name {}'.format)
+    recommended_minimum = 1
+
+    class Meta:
+        model = DocumentCategory
+
+
 class ProjectFactory(factory.DjangoModelFactory):
     submission = factory.SubFactory(ApplicationSubmissionFactory)
     user = factory.SubFactory(UserFactory)
@@ -49,3 +58,14 @@ class ProjectFactory(factory.DjangoModelFactory):
 
     class Meta:
         model = Project
+
+
+class PacketFileFactory(factory.DjangoModelFactory):
+    category = factory.SubFactory(DocumentCategoryFactory)
+    project = factory.SubFactory(ProjectFactory)
+
+    title = factory.Sequence('name {}'.format)
+    document = factory.django.FileField()
+
+    class Meta:
+        model = PacketFile
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index 09754f23ec9a9330e7fc26e77bf83f65a800365c..ded83fe644999416caf85afe0ef196c180b707fd 100644
--- a/opentech/apply/projects/tests/test_models.py
+++ b/opentech/apply/projects/tests/test_models.py
@@ -3,6 +3,8 @@ from django.test import TestCase
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
 
 from ..models import Project
+from .factories import (DocumentCategoryFactory, PacketFileFactory,
+                        ProjectFactory)
 
 
 class TestProjectModel(TestCase):
@@ -14,3 +16,46 @@ class TestProjectModel(TestCase):
         self.assertEquals(project.submission, submission)
         self.assertEquals(project.title, submission.title)
         self.assertEquals(project.user, submission.user)
+
+    def test_get_missing_document_categories_with_enough_documents(self):
+        project = ProjectFactory()
+        category = DocumentCategoryFactory(recommended_minimum=1)
+        PacketFileFactory(project=project, category=category)
+
+        self.assertEqual(project.packet_files.count(), 1)
+
+        missing = list(project.get_missing_document_categories())
+
+        self.assertEqual(len(missing), 0)
+
+    def test_get_missing_document_categories_with_no_documents(self):
+        project = ProjectFactory()
+        category = DocumentCategoryFactory(recommended_minimum=1)
+
+        self.assertEqual(project.packet_files.count(), 0)
+
+        missing = list(project.get_missing_document_categories())
+
+        self.assertEqual(len(missing), 1)
+        self.assertEqual(missing[0]['category'], category)
+        self.assertEqual(missing[0]['difference'], 1)
+
+    def test_get_missing_document_categories_with_some_documents(self):
+        project = ProjectFactory()
+
+        category1 = DocumentCategoryFactory(recommended_minimum=5)
+        PacketFileFactory(project=project, category=category1)
+        PacketFileFactory(project=project, category=category1)
+
+        category2 = DocumentCategoryFactory(recommended_minimum=3)
+        PacketFileFactory(project=project, category=category2)
+
+        self.assertEqual(project.packet_files.count(), 3)
+
+        missing = list(project.get_missing_document_categories())
+
+        self.assertEqual(len(missing), 2)
+        self.assertEqual(missing[0]['category'], category1)
+        self.assertEqual(missing[0]['difference'], 3)
+        self.assertEqual(missing[1]['category'], category2)
+        self.assertEqual(missing[1]['difference'], 2)
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index c234c9141fb0eb03bd3320a9666d601f07692493..f30bdd547e7111dd26bedfd24615aa6c213313b3 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -1,3 +1,5 @@
+from io import BytesIO
+
 from django.test import RequestFactory, TestCase
 
 from opentech.apply.users.tests.factories import (ReviewerFactory,
@@ -8,7 +10,7 @@ from opentech.apply.utils.testing.tests import BaseViewTestCase
 
 from ..forms import SetPendingForm
 from ..views import ProjectDetailView
-from .factories import ProjectFactory
+from .factories import DocumentCategoryFactory, ProjectFactory
 
 
 class TestCreateApprovalView(BaseViewTestCase):
@@ -104,3 +106,31 @@ class TestSendForApprovalView(BaseViewTestCase):
 
         self.assertTrue(project.is_locked)
         self.assertEqual(project.status, 'committed')
+
+
+class TestUploadDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_upload_document(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory()
+
+        test_doc = BytesIO(b'somebinarydata')
+        test_doc.name = 'document.pdf'
+
+        response = self.post_page(project, {
+            'form-submitted-document_form': '',
+            'title': 'test document',
+            'category': category.id,
+            'document': test_doc,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.packet_files.count(), 1)
diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py
index 984764f17e432d0e7b0de5c81720ce82df607c1d..38059fa387ac356a5055b21e4ed6c3e987e694a9 100644
--- a/opentech/apply/projects/views.py
+++ b/opentech/apply/projects/views.py
@@ -12,8 +12,8 @@ from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin,
                                         ViewDispatcher)
 
 from .forms import (CreateApprovalForm, ProjectEditForm, RejectionForm,
-                    SetPendingForm, UpdateProjectLeadForm)
-from .models import CONTRACTING, Approval, DocumentCategory, Project
+                    SetPendingForm, UpdateProjectLeadForm, UploadDocumentForm)
+from .models import CONTRACTING, Approval, Project
 
 
 @method_decorator(staff_required, name='dispatch')
@@ -106,6 +106,28 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView):
         return response
 
 
+@method_decorator(staff_required, name='dispatch')
+class UploadDocumentView(DelegatedViewMixin, CreateView):
+    context_name = 'document_form'
+    form_class = UploadDocumentForm
+    model = Project
+
+    def form_valid(self, form):
+        project = self.kwargs['object']
+        form.instance.project = project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPLOAD_DOCUMENT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+            title=form.instance.title
+        )
+
+        return response
+
+
 class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView):
     form_views = [
         CommentFormView,
@@ -113,6 +135,7 @@ class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView)
         RejectionView,
         SendForApprovalView,
         UpdateLeadView,
+        UploadDocumentView,
     ]
     model = Project
     template_name_suffix = '_admin_detail'
@@ -120,7 +143,7 @@ class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView)
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context['approvals'] = self.object.approvals.distinct('by')
-        context['remaining_document_categories'] = DocumentCategory.objects.all()
+        context['remaining_document_categories'] = self.object.get_missing_document_categories()
         return context