diff --git a/addressfield/fields.py b/addressfield/fields.py
index 7692b9eb466616fc1bf94e1ee1ee077f6f4b8f1e..f039b7181e04d5af874dafcb857074ac020eb492 100644
--- a/addressfield/fields.py
+++ b/addressfield/fields.py
@@ -14,6 +14,10 @@ with open(filepath, encoding='utf8') as address_data:
 
 VALIDATION_DATA = {country['iso']: country for country in countries}
 
+ADDRESS_FIELDS_ORDER = [
+    'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country'
+]
+
 
 def flatten_data(data):
     flattened = dict()
diff --git a/opentech/apply/activity/admin.py b/opentech/apply/activity/admin.py
index eacc4bfbde9664469434699c67ef262ac8762e47..11d7238c6b4652b7912534b3786a58316ced1cec 100644
--- a/opentech/apply/activity/admin.py
+++ b/opentech/apply/activity/admin.py
@@ -12,9 +12,9 @@ class MessageInline(admin.TabularInline):
 
 
 class EventAdmin(admin.ModelAdmin):
-    list_display = ('type', 'by', 'when', 'submission')
+    list_display = ('type', 'by', 'when', 'source')
     list_filter = ('type', 'when')
-    readonly_fields = ('type', 'submission', 'when', 'by')
+    readonly_fields = ('type', 'source', 'when', 'by')
     inlines = (MessageInline,)
 
     def has_add_permission(self, request):
diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index 52a4d4da6bf0b039aeeb691f53ff51ae82794e04..8343c3694040ebf61dae516df709ac2c49b359cf 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -54,6 +54,8 @@ neat_related = {
     MESSAGES.REVIEW_OPINION: 'opinion',
     MESSAGES.DELETE_REVIEW: 'review',
     MESSAGES.EDIT_REVIEW: 'review',
+    MESSAGES.CREATED_PROJECT: 'submission',
+    MESSAGES.UPDATE_PROJECT_LEAD: 'old_lead',
 }
 
 
@@ -108,38 +110,38 @@ class AdapterBase:
     def recipients(self, message_type, **kwargs):
         raise NotImplementedError()
 
-    def batch_recipients(self, message_type, submissions, **kwargs):
+    def batch_recipients(self, message_type, sources, **kwargs):
         # Default batch recipients is to send a message to each of the recipients that would
         # receive a message under normal conditions
         return [
             {
-                'recipients': self.recipients(message_type, submission=submission, **kwargs),
-                'submissions': [submission]
+                'recipients': self.recipients(message_type, source=source, **kwargs),
+                'sources': [source]
             }
-            for submission in submissions
+            for source in sources
         ]
 
-    def process_batch(self, message_type, events, request, user, submissions, related=None, **kwargs):
-        events_by_submission = {
-            event.submission.id: event
+    def process_batch(self, message_type, events, request, user, sources, related=None, **kwargs):
+        events_by_source = {
+            event.source.id: event
             for event in events
         }
-        for recipient in self.batch_recipients(message_type, submissions, **kwargs):
+        for recipient in self.batch_recipients(message_type, sources, **kwargs):
             recipients = recipient['recipients']
-            submissions = recipient['submissions']
-            events = [events_by_submission[submission.id] for submission in submissions]
-            self.process_send(message_type, recipients, events, request, user, submissions=submissions, submission=None, related=related, **kwargs)
+            sources = recipient['sources']
+            events = [events_by_source[source.id] for source in sources]
+            self.process_send(message_type, recipients, events, request, user, sources=sources, source=None, related=related, **kwargs)
 
-    def process(self, message_type, event, request, user, submission, related=None, **kwargs):
-        recipients = self.recipients(message_type, submission=submission, related=related, **kwargs)
-        self.process_send(message_type, recipients, [event], request, user, submission, related=related, **kwargs)
+    def process(self, message_type, event, request, user, source, related=None, **kwargs):
+        recipients = self.recipients(message_type, source=source, related=related, **kwargs)
+        self.process_send(message_type, recipients, [event], request, user, source, related=related, **kwargs)
 
-    def process_send(self, message_type, recipients, events, request, user, submission, submissions=list(), related=None, **kwargs):
+    def process_send(self, message_type, recipients, events, request, user, source, sources=list(), related=None, **kwargs):
         kwargs = {
             'request': request,
             'user': user,
-            'submission': submission,
-            'submissions': submissions,
+            'source': source,
+            'sources': sources,
             'related': related,
             **kwargs,
         }
@@ -197,10 +199,10 @@ class ActivityAdapter(AdapterBase):
     messages = {
         MESSAGES.TRANSITION: 'handle_transition',
         MESSAGES.BATCH_TRANSITION: 'handle_batch_transition',
-        MESSAGES.NEW_SUBMISSION: 'Submitted {submission.title} for {submission.page.title}',
+        MESSAGES.NEW_SUBMISSION: 'Submitted {source.title} for {source.page.title}',
         MESSAGES.EDIT: 'Edited',
         MESSAGES.APPLICANT_EDIT: 'Edited',
-        MESSAGES.UPDATE_LEAD: 'Lead changed from {old_lead} to {submission.lead}',
+        MESSAGES.UPDATE_LEAD: 'Lead changed from {old_lead} to {source.lead}',
         MESSAGES.BATCH_UPDATE_LEAD: 'Batch Lead changed to {new_lead}',
         MESSAGES.DETERMINATION_OUTCOME: 'Sent a determination. Outcome: {determination.clean_outcome}',
         MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determination',
@@ -210,14 +212,19 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.PARTNERS_UPDATED: 'partners_updated',
         MESSAGES.NEW_REVIEW: 'Submitted a review',
         MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed',
-        MESSAGES.SCREENING: 'Screening status from {old_status} to {submission.screening_status}',
-        MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}'
+        MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}',
+        MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}',
+        MESSAGES.CREATED_PROJECT: '{user} has created Project',
+        MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}',
+        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval',
+        MESSAGES.APPROVE_PROJECT: '{user} has approved',
+        MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"',
     }
 
     def recipients(self, message_type, **kwargs):
         return [None]
 
-    def extra_kwargs(self, message_type, submission, submissions, **kwargs):
+    def extra_kwargs(self, message_type, source, sources, **kwargs):
         if message_type in [
                 MESSAGES.OPENED_SEALED,
                 MESSAGES.REVIEWERS_UPDATED,
@@ -225,11 +232,14 @@ class ActivityAdapter(AdapterBase):
                 MESSAGES.REVIEW_OPINION,
                 MESSAGES.BATCH_REVIEWERS_UPDATED,
                 MESSAGES.PARTNERS_UPDATED,
+                MESSAGES.APPROVE_PROJECT,
+                MESSAGES.REQUEST_PROJECT_CHANGE,
+                MESSAGES.SEND_FOR_APPROVAL,
         ]:
             return {'visibility': TEAM}
 
-        submission = submission or submissions[0]
-        if is_transition(message_type) and not submission.phase.permissions.can_view(submission.user):
+        source = source or sources[0]
+        if is_transition(message_type) and not source.phase.permissions.can_view(source.user):
             # User's shouldn't see status activity changes for stages that aren't visible to the them
             return {'visibility': TEAM}
         return {}
@@ -255,15 +265,15 @@ class ActivityAdapter(AdapterBase):
         ])
         return ' '.join(base)
 
-    def batch_determination(self, submissions, determinations, **kwargs):
-        submission = submissions[0]
+    def batch_determination(self, sources, determinations, **kwargs):
+        submission = sources[0]
         determination = determinations[submission.id]
         return self.messages[MESSAGES.DETERMINATION_OUTCOME].format(
             determination=determination,
-            submission=submission,
         )
 
-    def handle_transition(self, old_phase, submission, **kwargs):
+    def handle_transition(self, old_phase, source, **kwargs):
+        submission = source
         base_message = 'Progressed from {old_display} to {new_display}'
 
         new_phase = submission.phase
@@ -290,11 +300,12 @@ class ActivityAdapter(AdapterBase):
 
         return staff_message
 
-    def handle_batch_transition(self, transitions, submissions, **kwargs):
-        kwargs.pop('submission')
+    def handle_batch_transition(self, transitions, sources, **kwargs):
+        submissions = sources
+        kwargs.pop('source')
         for submission in submissions:
             old_phase = transitions[submission.id]
-            return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs)
+            return self.handle_transition(old_phase=old_phase, source=submission, **kwargs)
 
     def partners_updated(self, added, removed, **kwargs):
         message = ['Partners updated.']
@@ -308,23 +319,24 @@ class ActivityAdapter(AdapterBase):
 
         return ' '.join(message)
 
-    def send_message(self, message, user, submission, submissions, **kwargs):
+    def send_message(self, message, user, source, sources, **kwargs):
         from .models import Activity
         visibility = kwargs.get('visibility', ALL)
 
         try:
             # If this was a batch action we want to pull out the submission
-            submission = submissions[0]
+            source = sources[0]
         except IndexError:
             pass
 
         related = kwargs['related']
         if isinstance(related, dict):
             try:
-                related = related[submission.id]
+                related = related[source.id]
             except KeyError:
                 pass
 
+        # TODO resolve how related objects work with submission/project
         has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url'])
         if has_correct_fields and isinstance(related, models.Model):
             related_object = related
@@ -333,7 +345,7 @@ class ActivityAdapter(AdapterBase):
 
         Activity.actions.create(
             user=user,
-            submission=submission,
+            source=source,
             timestamp=timezone.now(),
             message=message,
             visibility=visibility,
@@ -345,29 +357,34 @@ class SlackAdapter(AdapterBase):
     adapter_type = "Slack"
     always_send = True
     messages = {
-        MESSAGES.NEW_SUBMISSION: 'A new submission has been submitted for {submission.page.title}: <{link}|{submission.title}>',
-        MESSAGES.UPDATE_LEAD: 'The lead of <{link}|{submission.title}> has been updated from {old_lead} to {submission.lead} by {user}',
+        MESSAGES.NEW_SUBMISSION: 'A new submission has been submitted for {source.page.title}: <{link}|{source.title}>',
+        MESSAGES.UPDATE_LEAD: 'The lead of <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}',
         MESSAGES.BATCH_UPDATE_LEAD: 'handle_batch_lead',
-        MESSAGES.COMMENT: 'A new {comment.visibility} comment has been posted on <{link}|{submission.title}> by {user}',
-        MESSAGES.EDIT: '{user} has edited <{link}|{submission.title}>',
-        MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{submission.title}>',
+        MESSAGES.COMMENT: 'A new {comment.visibility} comment has been posted on <{link}|{source.title}> by {user}',
+        MESSAGES.EDIT: '{user} has edited <{link}|{source.title}>',
+        MESSAGES.APPLICANT_EDIT: '{user} has edited <{link}|{source.title}>',
         MESSAGES.REVIEWERS_UPDATED: 'reviewers_updated',
         MESSAGES.BATCH_REVIEWERS_UPDATED: 'handle_batch_reviewers',
-        MESSAGES.PARTNERS_UPDATED: '{user} has updated the partners on <{link}|{submission.title}>',
-        MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{submission.title}>: {old_phase.display_name} → {submission.phase}',
+        MESSAGES.PARTNERS_UPDATED: '{user} has updated the partners on <{link}|{source.title}>',
+        MESSAGES.TRANSITION: '{user} has updated the status of <{link}|{source.title}>: {old_phase.display_name} → {source.phase}',
         MESSAGES.BATCH_TRANSITION: 'handle_batch_transition',
-        MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{submission.title}> was sent by email. Outcome: {determination.clean_outcome}',
+        MESSAGES.DETERMINATION_OUTCOME: 'A determination for <{link}|{source.title}> was sent by email. Outcome: {determination.clean_outcome}',
         MESSAGES.BATCH_DETERMINATION_OUTCOME: 'handle_batch_determination',
-        MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{submission.title}>',
-        MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{submission.title}> by {submission.user} has been invited to submit a proposal',
-        MESSAGES.NEW_REVIEW: '{user} has submitted a review for <{link}|{submission.title}>. Outcome: {review.outcome},  Score: {review.get_score_display}',
+        MESSAGES.PROPOSAL_SUBMITTED: 'A proposal has been submitted for review: <{link}|{source.title}>',
+        MESSAGES.INVITED_TO_PROPOSAL: '<{link}|{source.title}> by {source.user} has been invited to submit a proposal',
+        MESSAGES.NEW_REVIEW: '{user} has submitted a review for <{link}|{source.title}>. Outcome: {review.outcome},  Score: {review.get_score_display}',
         MESSAGES.READY_FOR_REVIEW: 'notify_reviewers',
-        MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{submission.title}>',
-        MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {submission}',
+        MESSAGES.OPENED_SEALED: '{user} has opened the sealed submission: <{link}|{source.title}>',
+        MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source.title}',
         MESSAGES.BATCH_READY_FOR_REVIEW: 'batch_notify_reviewers',
-        MESSAGES.DELETE_SUBMISSION: '{user} has deleted {submission.title}',
-        MESSAGES.DELETE_REVIEW: '{user} has deleted {review.author} review for <{link}|{submission.title}>.',
-        MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{submission.title}>.',
+        MESSAGES.DELETE_SUBMISSION: '{user} has deleted {source.title}',
+        MESSAGES.DELETE_REVIEW: '{user} has deleted {review.author} review for <{link}|{source.title}>.',
+        MESSAGES.CREATED_PROJECT: '{user} has created a Project: <{link}|{source.title}>.',
+        MESSAGES.UPDATE_PROJECT_LEAD: 'The lead of project <{link}|{source.title}> has been updated from {old_lead} to {source.lead} by {user}',
+        MESSAGES.EDIT_REVIEW: '{user} has edited {review.author} review for <{link}|{source.title}>.',
+        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval on project <{link}|{source.title}>.',
+        MESSAGES.APPROVE_PROJECT: '{user} has approved project <{link}|{source.title}>.',
+        MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes for project acceptance on <{link}|{source.title}>.',
     }
 
     def __init__(self):
@@ -375,47 +392,56 @@ class SlackAdapter(AdapterBase):
         self.destination = settings.SLACK_DESTINATION_URL
         self.target_room = settings.SLACK_DESTINATION_ROOM
 
-    def slack_links(self, links, submissions):
+    def slack_links(self, links, sources):
         return ', '.join(
-            f'<{links[submission.id]}|{submission.title}>'
-            for submission in submissions
+            f'<{links[source.id]}|{source.title}>'
+            for source in sources
         )
 
     def extra_kwargs(self, message_type, **kwargs):
-        submission = kwargs['submission']
-        submissions = kwargs['submissions']
+        source = kwargs['source']
+        sources = kwargs['sources']
         request = kwargs['request']
-        link = link_to(submission, request)
+        link = link_to(source, request)
         links = {
-            submission.id: link_to(submission, request)
-            for submission in submissions
+            source.id: link_to(source, request)
+            for source in sources
         }
         return {
             'link': link,
             'links': links,
         }
 
-    def recipients(self, message_type, submission, related, **kwargs):
-        recipients = [self.slack_id(submission.lead)]
+    def recipients(self, message_type, source, related, **kwargs):
+        if message_type == MESSAGES.SEND_FOR_APPROVAL:
+            return [
+                self.slack_id(user)
+                for user in User.objects.approvers()
+                if self.slack_id(user)
+            ]
+
+        recipients = [self.slack_id(source.lead)]
 
         # Notify second reviewer when first reviewer is done.
         if message_type == MESSAGES.NEW_REVIEW and related:
+            submission = source
             if submission.assigned.with_roles().count() == 2 and related.author.reviewer == submission.assigned.with_roles().first().reviewer:
                 recipients.append(self.slack_id(submission.assigned.with_roles().last().reviewer))
 
         return recipients
 
-    def batch_recipients(self, message_type, submissions, **kwargs):
+    def batch_recipients(self, message_type, sources, **kwargs):
         # We group the messages by lead
-        leads = User.objects.filter(id__in=submissions.values('lead'))
+        leads = User.objects.filter(id__in=sources.values('lead'))
         return [
             {
                 'recipients': [self.slack_id(lead)],
-                'submissions': submissions.filter(lead=lead),
+                'sources': sources.filter(lead=lead),
             } for lead in leads
         ]
 
-    def reviewers_updated(self, submission, link, user, added=list(), removed=list(), **kwargs):
+    def reviewers_updated(self, source, link, user, added=list(), removed=list(), **kwargs):
+        submission = source
         message = [f'{user} has updated the reviewers on <{link}|{submission.title}>.']
 
         if added:
@@ -428,7 +454,8 @@ class SlackAdapter(AdapterBase):
 
         return ' '.join(message)
 
-    def handle_batch_lead(self, submissions, links, user, new_lead, **kwargs):
+    def handle_batch_lead(self, sources, links, user, new_lead, **kwargs):
+        submissions = sources
         submissions_text = self.slack_links(links, submissions)
         return (
             '{user} has batch changed lead to {new_lead} on: {submissions_text}'.format(
@@ -438,7 +465,8 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def handle_batch_reviewers(self, submissions, links, user, added, **kwargs):
+    def handle_batch_reviewers(self, sources, links, user, added, **kwargs):
+        submissions = sources
         submissions_text = self.slack_links(links, submissions)
         reviewers_text = ' '.join([
             f'{str(user)} as {role.name},'
@@ -453,7 +481,8 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def handle_batch_transition(self, user, links, submissions, transitions, **kwargs):
+    def handle_batch_transition(self, user, links, sources, transitions, **kwargs):
+        submissions = sources
         submissions_text = [
             ': '.join([
                 self.slack_links(links, [submission]),
@@ -469,7 +498,8 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def handle_batch_determination(self, submissions, links, determinations, **kwargs):
+    def handle_batch_determination(self, sources, links, determinations, **kwargs):
+        submissions = sources
         submissions_links = ','.join([
             self.slack_links(links, [submission])
             for submission in submissions
@@ -484,7 +514,8 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def notify_reviewers(self, submission, link, **kwargs):
+    def notify_reviewers(self, source, link, **kwargs):
+        submission = source
         reviewers_to_notify = []
         for reviewer in submission.reviewers.all():
             if submission.phase.permissions.can_review(reviewer):
@@ -502,23 +533,27 @@ class SlackAdapter(AdapterBase):
             )
         )
 
-    def batch_notify_reviewers(self, submissions, links, **kwargs):
-        kwargs.pop('submission')
+    def batch_notify_reviewers(self, sources, links, **kwargs):
+        kwargs.pop('source')
         kwargs.pop('link')
         return '. '.join(
-            self.notify_reviewers(submission, link=links[submission.id], **kwargs)
-            for submission in submissions
+            self.notify_reviewers(source, link=links[source.id], **kwargs)
+            for source in sources
         )
 
     def slack_id(self, user):
-        if user.slack:
-            return f'<{user.slack}>'
-        return ''
+        if user is None:
+            return ''
 
-    def slack_channels(self, submission):
+        if not user.slack:
+            return ''
+
+        return f'<{user.slack}>'
+
+    def slack_channels(self, source):
         target_rooms = [self.target_room]
         try:
-            extra_rooms = submission.get_from_parent('slack_channel').split(',')
+            extra_rooms = source.get_from_parent('slack_channel').split(',')
         except AttributeError:
             # Not a submission object, no extra rooms.
             pass
@@ -534,8 +569,8 @@ class SlackAdapter(AdapterBase):
 
         return target_rooms
 
-    def send_message(self, message, recipient, submission, **kwargs):
-        target_rooms = self.slack_channels(submission)
+    def send_message(self, message, recipient, source, **kwargs):
+        target_rooms = self.slack_channels(source)
 
         if not self.destination or not any(target_rooms):
             errors = list()
@@ -573,21 +608,25 @@ class EmailAdapter(AdapterBase):
         MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner',
     }
 
-    def get_subject(self, message_type, submission):
-        if submission:
+    def get_subject(self, message_type, source):
+        if source:
             if is_ready_for_review(message_type):
-                subject = 'Application ready to review: {submission.title}'.format(submission=submission)
+                subject = 'Application ready to review: {submission.title}'.format(submission=source)
             else:
-                subject = submission.page.specific.subject or 'Your application to {org_long_name}: {submission.title}'.format(org_long_name=settings.ORG_LONG_NAME, submission=submission)
+                try:
+                    subject = source.page.specific.subject or 'Your application to {org_long_name}: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source)
+                except AttributeError:
+                    subject = 'Your {org_long_name} Project: {source.title}'.format(org_long_name=settings.ORG_LONG_NAME, source=source)
             return subject
 
-    def extra_kwargs(self, message_type, submission, submissions, **kwargs):
+    def extra_kwargs(self, message_type, source, sources, **kwargs):
         return {
-            'subject': self.get_subject(message_type, submission),
+            'subject': self.get_subject(message_type, source),
         }
 
-    def handle_transition(self, old_phase, submission, **kwargs):
+    def handle_transition(self, old_phase, source, **kwargs):
         from opentech.apply.funds.workflow import PHASES
+        submission = source
         # Retrive status index to see if we are going forward or backward.
         old_index = list(dict(PHASES).keys()).index(old_phase.name)
         target_index = list(dict(PHASES).keys()).index(submission.status)
@@ -596,71 +635,73 @@ class EmailAdapter(AdapterBase):
         if is_forward:
             return self.render_message(
                 'messages/email/transition.html',
-                submission=submission,
+                source=submission,
                 old_phase=old_phase,
                 **kwargs
             )
 
-    def handle_batch_transition(self, transitions, submissions, **kwargs):
-        kwargs.pop('submission')
+    def handle_batch_transition(self, transitions, sources, **kwargs):
+        submissions = sources
+        kwargs.pop('source')
         for submission in submissions:
             old_phase = transitions[submission.id]
-            return self.handle_transition(old_phase=old_phase, submission=submission, **kwargs)
+            return self.handle_transition(old_phase=old_phase, source=submission, **kwargs)
 
-    def batch_determination(self, determinations, submissions, **kwargs):
-        kwargs.pop('submission')
+    def batch_determination(self, determinations, sources, **kwargs):
+        submissions = sources
+        kwargs.pop('source')
         for submission in submissions:
             determination = determinations[submission.id]
             return self.render_message(
                 'messages/email/determination.html',
-                submission=submission,
+                source=submission,
                 determination=determination,
                 **kwargs
             )
 
     def notify_comment(self, **kwargs):
         comment = kwargs['comment']
-        submission = kwargs['submission']
-        if not comment.priviledged and not comment.user == submission.user:
+        source = kwargs['source']
+        if not comment.priviledged and not comment.user == source.user:
             return self.render_message('messages/email/comment.html', **kwargs)
 
-    def recipients(self, message_type, submission, **kwargs):
+    def recipients(self, message_type, source, **kwargs):
         if is_ready_for_review(message_type):
-            return self.reviewers(submission)
+            return self.reviewers(source)
 
         if is_transition(message_type):
             # Only notify the applicant if the new phase can be seen within the workflow
-            if not submission.phase.permissions.can_view(submission.user):
+            if not source.phase.permissions.can_view(source.user):
                 return []
 
         if message_type == MESSAGES.PARTNERS_UPDATED_PARTNER:
             partners = kwargs['added']
             return [partner.email for partner in partners]
 
-        return [submission.user.email]
+        return [source.user.email]
 
-    def batch_recipients(self, message_type, submissions, **kwargs):
+    def batch_recipients(self, message_type, sources, **kwargs):
         if not is_ready_for_review(message_type):
-            return super().batch_recipients(message_type, submissions, **kwargs)
+            return super().batch_recipients(message_type, sources, **kwargs)
 
         reviewers_to_message = defaultdict(list)
-        for submission in submissions:
-            reviewers = self.reviewers(submission)
+        for source in sources:
+            reviewers = self.reviewers(source)
             for reviewer in reviewers:
-                reviewers_to_message[reviewer].append(submission)
+                reviewers_to_message[reviewer].append(source)
 
         return [
             {
                 'recipients': [reviewer],
-                'submissions': submissions,
-            } for reviewer, submissions in reviewers_to_message.items()
+                'sources': sources,
+            } for reviewer, sources in reviewers_to_message.items()
         ]
 
-    def reviewers(self, submission):
+    def reviewers(self, source):
         return [
             reviewer.email
-            for reviewer in submission.missing_reviewers.all()
-            if submission.phase.permissions.can_review(reviewer) and not reviewer.is_apply_staff
+            for reviewer in source.missing_reviewers.all()
+            if source.phase.permissions.can_review(reviewer) and not reviewer.is_apply_staff
         ]
 
     def partners_updated_applicant(self, added, removed, **kwargs):
@@ -678,12 +719,12 @@ class EmailAdapter(AdapterBase):
     def render_message(self, template, **kwargs):
         return render_to_string(template, kwargs)
 
-    def send_message(self, message, submission, subject, recipient, logs, **kwargs):
+    def send_message(self, message, source, subject, recipient, logs, **kwargs):
         try:
             send_mail(
                 subject,
                 message,
-                submission.page.specific.from_address,
+                source.page.specific.from_address,
                 [recipient],
                 logs=logs
             )
@@ -699,9 +740,11 @@ 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}"',
+        MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document "{title}"',
     }
 
-    def batch_reviewers_updated(self, added, submissions, **kwargs):
+    def batch_reviewers_updated(self, added, sources, **kwargs):
         reviewers_text = ' '.join([
             f'{str(user)} as {role.name},'
             for role, user in added
@@ -712,10 +755,10 @@ class DjangoMessagesAdapter(AdapterBase):
             'Batch reviewers added: ' +
             reviewers_text +
             ' to ' +
-            ', '.join(['"{}"'.format(submission.title) for submission in submissions])
+            ', '.join(['"{}"'.format(source.title) for source in sources])
         )
 
-    def batch_transition(self, submissions, transitions, **kwargs):
+    def batch_transition(self, sources, transitions, **kwargs):
         base_message = 'Successfully updated:'
         transition = '{submission} [{old_display} → {new_display}].'
         transition_messages = [
@@ -723,12 +766,13 @@ class DjangoMessagesAdapter(AdapterBase):
                 submission=submission.title,
                 old_display=transitions[submission.id],
                 new_display=submission.phase,
-            ) for submission in submissions
+            ) for submission in sources
         ]
         messages = [base_message, *transition_messages]
         return ' '.join(messages)
 
-    def batch_determinations(self, submissions, determinations, **kwargs):
+    def batch_determinations(self, sources, determinations, **kwargs):
+        submissions = sources
         outcome = determinations[submissions[0].id].clean_outcome
 
         base_message = f'Successfully determined as {outcome}: '
@@ -740,10 +784,10 @@ class DjangoMessagesAdapter(AdapterBase):
     def recipients(self, *args, **kwargs):
         return [None]
 
-    def batch_recipients(self, message_type, submissions, *args, **kwargs):
+    def batch_recipients(self, message_type, sources, *args, **kwargs):
         return [{
             'recipients': [None],
-            'submissions': submissions,
+            'sources': sources,
         }]
 
     def send_message(self, message, request, **kwargs):
@@ -757,20 +801,20 @@ class MessengerBackend:
     def __call__(self, *args, related=None, **kwargs):
         return self.send(*args, related=related, **kwargs)
 
-    def send(self, message_type, request, user, related, submission=None, submissions=list(), **kwargs):
+    def send(self, message_type, request, user, related, source=None, sources=list(), **kwargs):
         from .models import Event
-        if submission:
-            event = Event.objects.create(type=message_type.name, by=user, submission=submission)
+        if source:
+            event = Event.objects.create(type=message_type.name, by=user, source=source)
             for adapter in self.adapters:
-                adapter.process(message_type, event, request=request, user=user, submission=submission, related=related, **kwargs)
+                adapter.process(message_type, event, request=request, user=user, source=source, related=related, **kwargs)
 
-        elif submissions:
+        elif sources:
             events = Event.objects.bulk_create(
-                Event(type=message_type.name, by=user, submission=submission)
-                for submission in submissions
+                Event(type=message_type.name, by=user, source=source)
+                for source in sources
             )
             for adapter in self.adapters:
-                adapter.process_batch(message_type, events, request=request, user=user, submissions=submissions, related=related, **kwargs)
+                adapter.process_batch(message_type, events, request=request, user=user, sources=sources, related=related, **kwargs)
 
 
 adapters = [
diff --git a/opentech/apply/activity/migrations/0026_add_created_project_event.py b/opentech/apply/activity/migrations/0026_add_created_project_event.py
new file mode 100644
index 0000000000000000000000000000000000000000..05053e4d20eb47adac7592943079fef55e4d11eb
--- /dev/null
+++ b/opentech/apply/activity/migrations/0026_add_created_project_event.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-07-29 10:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0025_add_batch_lead_event'),
+    ]
+
+    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'), ('EDIT_REVIEW', 'Edit Review')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0027_add_update_project_lead.py b/opentech/apply/activity/migrations/0027_add_update_project_lead.py
new file mode 100644
index 0000000000000000000000000000000000000000..e10f100e188fadfe2c0e0a3cd70c79f1574a2427
--- /dev/null
+++ b/opentech/apply/activity/migrations/0027_add_update_project_lead.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-01 09:44
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0026_add_created_project_event'),
+    ]
+
+    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')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0028_add_new_generic_relation.py b/opentech/apply/activity/migrations/0028_add_new_generic_relation.py
new file mode 100644
index 0000000000000000000000000000000000000000..545a19f6e63d9c25da3d839fffa4021b0d200f4f
--- /dev/null
+++ b/opentech/apply/activity/migrations/0028_add_new_generic_relation.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.0.13 on 2019-07-10 17:07
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('activity', '0027_add_update_project_lead'),
+    ]
+
+    operations = [
+        # Updates to the existing GenericForeignKey to related objects
+        migrations.RenameField(
+            model_name='activity',
+            old_name='object_id',
+            new_name='related_object_id',
+        ),
+        migrations.RenameField(
+            model_name='activity',
+            old_name='content_type',
+            new_name='related_content_type',
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='related_content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity_related', to='contenttypes.ContentType'),
+        ),
+        # Add the new generic foreign key
+        migrations.AddField(
+            model_name='activity',
+            name='source_content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='activity_source', to='contenttypes.ContentType'),
+        ),
+        migrations.AddField(
+            model_name='activity',
+            name='source_object_id',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        # Make the submission field nullable
+        migrations.AlterField(
+            model_name='activity',
+            name='submission',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='funds.ApplicationSubmission', related_name='activities', null=True)
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py b/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c487b0f893f8f2f4cdc98e8592bea273cb2890d
--- /dev/null
+++ b/opentech/apply/activity/migrations/0029_migrate_old_submission_relation.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.0.13 on 2019-07-10 17:33
+
+from django.db import migrations
+from django.db.models import F
+
+
+def submission_to_source(apps, schema_editor):
+    Activity = apps.get_model('activity', 'Activity')
+    if Activity.objects.exists():
+        ContentType = apps.get_model('contenttypes', 'ContentType')
+        content_type = ContentType.objects.get(model='applicationsubmission', app_label='funds')
+        Activity.objects.update(
+            source_object_id=F('submission_id'),
+            source_content_type=content_type,
+        )
+
+
+def source_to_submission(apps, schema_editor):
+    Activity = apps.get_model('activity', 'Activity')
+    Activity.objects.update(submission_id=F('source_object_id'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0028_add_new_generic_relation'),
+        ('funds', '0065_applicationsubmission_meta_categories'),
+        ('contenttypes', '0002_remove_content_type_name'),
+    ]
+
+    operations = [
+        migrations.RunPython(submission_to_source, source_to_submission)
+    ]
diff --git a/opentech/apply/activity/migrations/0030_remove_old_relation.py b/opentech/apply/activity/migrations/0030_remove_old_relation.py
new file mode 100644
index 0000000000000000000000000000000000000000..b5242315763380709c4e22a09c04c7fcbe7ed47b
--- /dev/null
+++ b/opentech/apply/activity/migrations/0030_remove_old_relation.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.0.13 on 2019-07-10 17:33
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0029_migrate_old_submission_relation'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='activity',
+            name='submission',
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py b/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py
new file mode 100644
index 0000000000000000000000000000000000000000..f67b8a245e10d3dc927a2def3ca9ab80182200d9
--- /dev/null
+++ b/opentech/apply/activity/migrations/0031_add_generic_fk_to_event.py
@@ -0,0 +1,30 @@
+# Generated by Django 2.0.13 on 2019-07-10 22:35
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('activity', '0030_remove_old_relation'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='submission',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='funds.ApplicationSubmission'),
+        ),
+        migrations.AddField(
+            model_name='event',
+            name='content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
+        ),
+        migrations.AddField(
+            model_name='event',
+            name='object_id',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py b/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py
new file mode 100644
index 0000000000000000000000000000000000000000..137d80d9c0c49a3955ad6bebc1242c2dc2d6c8e3
--- /dev/null
+++ b/opentech/apply/activity/migrations/0032_migrate_submission_to_generic_event.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.0.13 on 2019-07-10 22:36
+
+from django.db import migrations
+from django.db.models import F
+
+
+def submission_to_source(apps, schema_editor):
+    Event = apps.get_model('activity', 'Event')
+    if Event.objects.exists():
+        ContentType = apps.get_model('contenttypes', 'ContentType')
+        content_type = ContentType.objects.get(model='applicationsubmission', app_label='funds')
+        Event.objects.update(
+            object_id=F('submission_id'),
+            content_type=content_type,
+        )
+
+
+def source_to_submission(apps, schema_editor):
+    Event = apps.get_model('activity', 'Event')
+    Event.objects.update(submission_id=F('object_id'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0031_add_generic_fk_to_event'),
+        ('funds', '0065_applicationsubmission_meta_categories'),
+        ('contenttypes', '0002_remove_content_type_name'),
+    ]
+
+    operations = [
+        migrations.RunPython(submission_to_source, source_to_submission)
+    ]
diff --git a/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py b/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e76833da651d60e66a2f5d0a57061c9955c58c2
--- /dev/null
+++ b/opentech/apply/activity/migrations/0033_remove_old_submission_fk_event.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.0.13 on 2019-07-10 22:40
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0032_migrate_submission_to_generic_event'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='event',
+            name='submission',
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0034_add_send_for_approval.py b/opentech/apply/activity/migrations/0034_add_send_for_approval.py
new file mode 100644
index 0000000000000000000000000000000000000000..4dcb14b42ac373f6837dca31c7a84ec74b1ef01d
--- /dev/null
+++ b/opentech/apply/activity/migrations/0034_add_send_for_approval.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-05 16:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0033_remove_old_submission_fk_event'),
+    ]
+
+    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')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0035_add_approve_project.py b/opentech/apply/activity/migrations/0035_add_approve_project.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1ebdd9e7be7c7f4a635396b69c379c35ee1e891
--- /dev/null
+++ b/opentech/apply/activity/migrations/0035_add_approve_project.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-07 13:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0034_add_send_for_approval'),
+    ]
+
+    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')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0036_add_reject_project.py b/opentech/apply/activity/migrations/0036_add_reject_project.py
new file mode 100644
index 0000000000000000000000000000000000000000..640d50dd02f33fe548e24d29d49056766fb341fe
--- /dev/null
+++ b/opentech/apply/activity/migrations/0036_add_reject_project.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-07 13:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0035_add_approve_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'), ('REQUEST_PROJECT_CHANGE', 'Project change requested')], max_length=50),
+        ),
+    ]
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/migrations/0039_add_remove_document.py b/opentech/apply/activity/migrations/0039_add_remove_document.py
new file mode 100644
index 0000000000000000000000000000000000000000..c3014c711c3e30a5e0cdb3b7c8cc87085c462d49
--- /dev/null
+++ b/opentech/apply/activity/migrations/0039_add_remove_document.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-08 14:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0038_auto_20190808_1142'),
+    ]
+
+    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'), ('REMOVE_DOCUMENT', 'Document was Removed from Project')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py b/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9ba43047688e6c900a77cae589184bfba6f3a22
--- /dev/null
+++ b/opentech/apply/activity/migrations/0040_merge_activity_update_and_generic_relations.py
@@ -0,0 +1,14 @@
+# Generated by Django 2.0.13 on 2019-08-13 18:04
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0028_migrate_messages_with_visibility'),
+        ('activity', '0039_add_remove_document'),
+    ]
+
+    operations = [
+    ]
diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py
index f6dd90837e2f0e5308df19b870ced4d1b0d7b902..44cd95989953279d6fde646bd0c7f3b323e2b85c 100644
--- a/opentech/apply/activity/models.py
+++ b/opentech/apply/activity/models.py
@@ -88,7 +88,11 @@ class Activity(models.Model):
     timestamp = models.DateTimeField()
     type = models.CharField(choices=ACTIVITY_TYPES.items(), max_length=30)
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
-    submission = models.ForeignKey('funds.ApplicationSubmission', related_name='activities', on_delete=models.CASCADE)
+
+    source_content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE, related_name='activity_source')
+    source_object_id = models.PositiveIntegerField(blank=True, null=True)
+    source = GenericForeignKey('source_content_type', 'source_object_id')
+
     message = models.TextField()
     visibility = models.CharField(choices=list(VISIBILITY.items()), default=APPLICANT, max_length=30)
 
@@ -98,9 +102,9 @@ class Activity(models.Model):
     previous = models.ForeignKey("self", on_delete=models.CASCADE, null=True)
 
     # Fields for generic relations to other objects. related_object should implement `get_absolute_url`
-    content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE)
-    object_id = models.PositiveIntegerField(blank=True, null=True)
-    related_object = GenericForeignKey('content_type', 'object_id')
+    related_content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE, related_name='activity_related')
+    related_object_id = models.PositiveIntegerField(blank=True, null=True)
+    related_object = GenericForeignKey('related_content_type', 'related_object_id')
 
     objects = models.Manager.from_queryset(ActivityQuerySet)()
     comments = CommentManger.from_queryset(CommentQueryset)()
@@ -145,7 +149,9 @@ class Event(models.Model):
     when = models.DateTimeField(auto_now_add=True)
     type = models.CharField(choices=MESSAGES.choices(), max_length=50)
     by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
-    submission = models.ForeignKey('funds.ApplicationSubmission', related_name='+', on_delete=models.CASCADE)
+    content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE)
+    object_id = models.PositiveIntegerField(blank=True, null=True)
+    source = GenericForeignKey('content_type', 'object_id')
 
     def __str__(self):
         return ' '.join([self.get_type_display(), 'by:', str(self.by), 'on:', self.submission.title])
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index 1fd7988a748fff6ca610a28d3f8c6210913204f5..1dacb5fe6c8b5480174f6815d95aba5dbb01e46e 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -26,7 +26,14 @@ class MESSAGES(Enum):
     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'
+    REMOVE_DOCUMENT = 'Document was Removed from Project'
 
     @classmethod
     def choices(cls):
diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html
index 95352d4d922dce15b32b2ffbb69523a802752db1..bdc55edf7ae7fa60bed5332b66916ae77fc3995e 100644
--- a/opentech/apply/activity/templates/activity/include/listing_base.html
+++ b/opentech/apply/activity/templates/activity/include/listing_base.html
@@ -32,7 +32,7 @@
 
         <p class="feed__heading">
             {% if submission_title %}
-                updated <a href="{{ activity.submission.get_absolute_url }}">{{ activity.submission.title }}</a>
+                updated <a href="{{ activity.source.get_absolute_url }}">{{ activity.source.title }}</a>
             {% endif %}
 
             {% if editable %}
diff --git a/opentech/apply/activity/templates/messages/email/applicant_base.html b/opentech/apply/activity/templates/messages/email/applicant_base.html
index 714bb3f013be3ce954b5aa7ef6cfaacfbc66b9e9..6ddf443bc8ea35c2364102ec4e189d76640494ce 100644
--- a/opentech/apply/activity/templates/messages/email/applicant_base.html
+++ b/opentech/apply/activity/templates/messages/email/applicant_base.html
@@ -1,8 +1,8 @@
 {% extends "messages/email/base.html" %}
-{% block salutation %}Dear {{ submission.user.get_full_name|default:"applicant" }},{% endblock %}
+{% block salutation %}Dear {{ source.user.get_full_name|default:"applicant" }},{% endblock %}
 
-{% block more_info %}You can access your application here: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}
-If you have any questions, please submit them here: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}#communications
+{% block more_info %}You can find more information here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+If you have any questions, please submit them here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}#communications
 
 If you have any issues accessing the submission system or other general inquiries, please email us at {{ ORG_EMAIL }}
 {% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html
index 25d2aa7a6ab25a1c1c7e99841e5af8449ffde663..19ae54272ca3f2d5f68508bcd02822e953907028 100644
--- a/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html
+++ b/opentech/apply/activity/templates/messages/email/batch_ready_to_review.html
@@ -3,7 +3,7 @@
 
 {% block content %}
 New proposals have been added to your review list.
-{% for submission in submissions %}
+{% for submission in sources %}
 
 Title: {{ submission.title }}
 Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}
diff --git a/opentech/apply/activity/templates/messages/email/comment.html b/opentech/apply/activity/templates/messages/email/comment.html
index c092483e37e1ed63db7ece92aa2fd89938793761..e89ca785f5cd7252b0251d08d72a4bb4c493aedd 100644
--- a/opentech/apply/activity/templates/messages/email/comment.html
+++ b/opentech/apply/activity/templates/messages/email/comment.html
@@ -1,5 +1,5 @@
 {% extends "messages/email/applicant_base.html" %}
 
-{% block content %}There has been a new comment on your application: {{ submission.title }}
+{% block content %}There has been a new comment on "{{ source.title }}"
 
 {{ comment.user }}: {{ comment.message }}{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/ready_to_review.html b/opentech/apply/activity/templates/messages/email/ready_to_review.html
index 1e9b3e67ca7bd63f593b20eb58edc065927f1728..98dabcef3f002a23eb79ce874ca2ccb236778fc4 100644
--- a/opentech/apply/activity/templates/messages/email/ready_to_review.html
+++ b/opentech/apply/activity/templates/messages/email/ready_to_review.html
@@ -4,6 +4,6 @@
 {% block content %}
 A new proposal has been added to your review list.
 
-Title: {{ submission.title }}
-Link: {{ request.scheme }}://{{ request.get_host }}{{ submission.get_absolute_url }}
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
 {% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/transition.html b/opentech/apply/activity/templates/messages/email/transition.html
index 522c08cf89bd1af54aab399d6313418cf887ea0c..4ac07ed0f1fca420ab93fd4fd95d47cba9d7d646 100644
--- a/opentech/apply/activity/templates/messages/email/transition.html
+++ b/opentech/apply/activity/templates/messages/email/transition.html
@@ -1,3 +1,3 @@
 {% extends "messages/email/applicant_base.html" %}
 
-{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ submission.phase.public_name }}.{% endblock %}
+{% block content %}Your application has been progressed from {{ old_phase.public_name }} to {{ source.phase.public_name }}.{% endblock %}
diff --git a/opentech/apply/activity/templatetags/activity_tags.py b/opentech/apply/activity/templatetags/activity_tags.py
index 412c64aec2ad63ef11b7123c5cb12d0886184208..9a1aa5e4e087067dbcccd49b74b4042f78827bc0 100644
--- a/opentech/apply/activity/templatetags/activity_tags.py
+++ b/opentech/apply/activity/templatetags/activity_tags.py
@@ -12,7 +12,7 @@ register = template.Library()
 
 @register.filter
 def display_author(activity, user):
-    if activity.submission.user == user and isinstance(activity.related_object, Review):
+    if isinstance(activity.related_object, Review) and activity.source.user == user:
         return 'Reviewer'
     return activity.user
 
diff --git a/opentech/apply/activity/tests/factories.py b/opentech/apply/activity/tests/factories.py
index 9c379c3c6c106c0a27a0dc1c8cc6e67fe497c51d..51a1b3fb57678e39aa849ad87582fe2b20977a86 100644
--- a/opentech/apply/activity/tests/factories.py
+++ b/opentech/apply/activity/tests/factories.py
@@ -16,7 +16,7 @@ class CommentFactory(factory.DjangoModelFactory):
         internal = factory.Trait(visibility=TEAM)
         reviewers = factory.Trait(visibility=REVIEWER)
 
-    submission = factory.SubFactory(ApplicationSubmissionFactory)
+    source = factory.SubFactory(ApplicationSubmissionFactory)
     user = factory.SubFactory(UserFactory)
     message = factory.Faker('sentence')
     timestamp = factory.LazyFunction(timezone.now)
@@ -32,7 +32,7 @@ class EventFactory(factory.DjangoModelFactory):
 
     type = factory.Iterator([choice[0] for choice in MESSAGES.choices()])
     by = factory.SubFactory(UserFactory)
-    submission = factory.SubFactory(ApplicationSubmissionFactory)
+    source = factory.SubFactory(ApplicationSubmissionFactory)
 
 
 class MessageFactory(factory.DjangoModelFactory):
diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py
index 447c1065275817c952f4a01a4be6f496e89a6efa..6b6262de277e6f06d1b85181568393affaabe9d9 100644
--- a/opentech/apply/activity/tests/test_messaging.py
+++ b/opentech/apply/activity/tests/test_messaging.py
@@ -17,6 +17,7 @@ from opentech.apply.funds.tests.factories import (
 )
 from opentech.apply.review.tests.factories import ReviewFactory
 from opentech.apply.users.tests.factories import ReviewerFactory, UserFactory
+from opentech.apply.projects.tests.factories import ProjectFactory
 
 from ..models import Activity, Event, Message, TEAM, ALL
 from ..messaging import (
@@ -52,12 +53,13 @@ class TestAdapter(AdapterBase):
 @override_settings(ROOT_URLCONF='opentech.apply.urls')
 class AdapterMixin(TestCase):
     adapter = None
+    source_factory = None
 
     def process_kwargs(self, message_type, **kwargs):
         if 'user' not in kwargs:
             kwargs['user'] = UserFactory()
-        if 'submission' not in kwargs:
-            kwargs['submission'] = ApplicationSubmissionFactory()
+        if 'source' not in kwargs:
+            kwargs['source'] = self.source_factory()
         if 'request' not in kwargs:
             kwargs['request'] = make_request()
         if message_type in neat_related:
@@ -67,13 +69,18 @@ class AdapterMixin(TestCase):
 
         return kwargs
 
-    def adapter_process(self, message_type, **kwargs):
+    def adapter_process(self, message_type, adapter=None, **kwargs):
+        if not adapter:
+            adapter = self.adapter
         kwargs = self.process_kwargs(message_type, **kwargs)
-        self.adapter.process(message_type, event=EventFactory(submission=kwargs['submission']), **kwargs)
+        event = EventFactory(source=kwargs['source'])
+        adapter.process(message_type, event=event, **kwargs)
 
 
 @override_settings(SEND_MESSAGES=True)
 class TestBaseAdapter(AdapterMixin, TestCase):
+    source_factory = ApplicationSubmissionFactory
+
     def setUp(self):
         patched_class = patch.object(TestAdapter, 'send_message')
         self.mock_adapter = patched_class.start()
@@ -145,7 +152,9 @@ class TestBaseAdapter(AdapterMixin, TestCase):
         self.assertTrue(self.adapter.adapter_type in messages[0].message)
 
 
-class TestMessageBackend(TestCase):
+class TestMessageBackendApplication(TestCase):
+    source_factory = ApplicationSubmissionFactory
+
     def setUp(self):
         self.mocked_adapter = Mock(AdapterBase)
         self.backend = MessengerBackend
@@ -153,7 +162,7 @@ class TestMessageBackend(TestCase):
             'related': None,
             'request': None,
             'user': UserFactory(),
-            'submission': ApplicationSubmissionFactory(),
+            'source': self.source_factory(),
         }
 
     def test_message_sent_to_adapter(self):
@@ -187,6 +196,10 @@ class TestMessageBackend(TestCase):
         self.assertEqual(Event.objects.first().by, user)
 
 
+class TestMessageBackendProject(TestMessageBackendApplication):
+    source_factory = ProjectFactory
+
+
 @override_settings(SEND_MESSAGES=True)
 class TestActivityAdapter(TestCase):
     def setUp(self):
@@ -197,13 +210,13 @@ class TestActivityAdapter(TestCase):
         user = UserFactory()
         submission = ApplicationSubmissionFactory()
 
-        self.adapter.send_message(message, user=user, submission=submission, submissions=[], related=None)
+        self.adapter.send_message(message, user=user, source=submission, sources=[], related=None)
 
         self.assertEqual(Activity.objects.count(), 1)
         activity = Activity.objects.first()
         self.assertEqual(activity.user, user)
         self.assertEqual(activity.message, message)
-        self.assertEqual(activity.submission, submission)
+        self.assertEqual(activity.source, submission)
 
     def test_reviewers_message_no_removed(self):
         assigned_reviewer = AssignedReviewersFactory()
@@ -247,13 +260,13 @@ class TestActivityAdapter(TestCase):
 
     def test_internal_transition_kwarg_for_invisible_transition(self):
         submission = ApplicationSubmissionFactory(status='post_review_discussion')
-        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None)
+        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, source=submission, sources=None)
 
         self.assertEqual(kwargs['visibility'], TEAM)
 
     def test_public_transition_kwargs(self):
         submission = ApplicationSubmissionFactory()
-        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, submission=submission, submissions=None)
+        kwargs = self.adapter.extra_kwargs(MESSAGES.TRANSITION, source=submission, sources=None)
 
         self.assertNotIn('visibility', kwargs)
 
@@ -293,7 +306,7 @@ class TestActivityAdapter(TestCase):
     def test_lead_not_saved_on_activity(self):
         submission = ApplicationSubmissionFactory()
         user = UserFactory()
-        self.adapter.send_message('a message', user=user, submission=submission, submissions=[], related=user)
+        self.adapter.send_message('a message', user=user, source=submission, sources=[], related=user)
         activity = Activity.objects.first()
         self.assertEqual(activity.related_object, None)
 
@@ -302,8 +315,8 @@ class TestActivityAdapter(TestCase):
         self.adapter.send_message(
             'a message',
             user=review.author.reviewer,
-            submission=review.submission,
-            submissions=[],
+            source=review.submission,
+            sources=[],
             related=review,
         )
         activity = Activity.objects.first()
@@ -311,6 +324,8 @@ class TestActivityAdapter(TestCase):
 
 
 class TestSlackAdapter(AdapterMixin, TestCase):
+    source_factory = ApplicationSubmissionFactory
+
     target_url = 'https://my-slack-backend.com/incoming/my-very-secret-key'
     target_room = '#<ROOM ID>'
 
@@ -322,7 +337,7 @@ class TestSlackAdapter(AdapterMixin, TestCase):
     def test_cant_send_with_no_room(self):
         adapter = SlackAdapter()
         submission = ApplicationSubmissionFactory()
-        adapter.send_message('my message', '', submission)
+        adapter.send_message('my message', '', source=submission)
         self.assertEqual(len(responses.calls), 0)
 
     @override_settings(
@@ -333,7 +348,7 @@ class TestSlackAdapter(AdapterMixin, TestCase):
     def test_cant_send_with_no_url(self):
         adapter = SlackAdapter()
         submission = ApplicationSubmissionFactory()
-        adapter.send_message('my message', '', submission)
+        adapter.send_message('my message', '', source=submission)
         self.assertEqual(len(responses.calls), 0)
 
     @override_settings(
@@ -346,7 +361,7 @@ class TestSlackAdapter(AdapterMixin, TestCase):
         submission = ApplicationSubmissionFactory()
         adapter = SlackAdapter()
         message = 'my message'
-        adapter.send_message(message, '', submission)
+        adapter.send_message(message, '', source=submission)
         self.assertEqual(len(responses.calls), 1)
         self.assertDictEqual(
             json.loads(responses.calls[0].request.body),
@@ -366,7 +381,7 @@ class TestSlackAdapter(AdapterMixin, TestCase):
         submission = ApplicationSubmissionFactory(page__slack_channel='dummy')
         adapter = SlackAdapter()
         message = 'my message'
-        adapter.send_message(message, '', submission)
+        adapter.send_message(message, '', source=submission)
         self.assertEqual(len(responses.calls), 1)
         self.assertDictEqual(
             json.loads(responses.calls[0].request.body),
@@ -380,14 +395,14 @@ class TestSlackAdapter(AdapterMixin, TestCase):
     def test_gets_lead_if_slack_set(self):
         adapter = SlackAdapter()
         submission = ApplicationSubmissionFactory()
-        recipients = adapter.recipients(MESSAGES.COMMENT, submission, related=None)
+        recipients = adapter.recipients(MESSAGES.COMMENT, source=submission, related=None)
         self.assertTrue(submission.lead.slack in recipients[0])
 
     @responses.activate
     def test_gets_blank_if_slack_not_set(self):
         adapter = SlackAdapter()
         submission = ApplicationSubmissionFactory(lead__slack='')
-        recipients = adapter.recipients(MESSAGES.COMMENT, submission, related=None)
+        recipients = adapter.recipients(MESSAGES.COMMENT, source=submission, related=None)
         self.assertTrue(submission.lead.slack in recipients[0])
 
     @override_settings(
@@ -423,11 +438,12 @@ class TestSlackAdapter(AdapterMixin, TestCase):
 
 @override_settings(SEND_MESSAGES=True)
 class TestEmailAdapter(AdapterMixin, TestCase):
+    source_factory = ApplicationSubmissionFactory
     adapter = EmailAdapter()
 
     def test_email_new_submission(self):
         submission = ApplicationSubmissionFactory()
-        self.adapter_process(MESSAGES.NEW_SUBMISSION, submission=submission)
+        self.adapter_process(MESSAGES.NEW_SUBMISSION, source=submission)
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(mail.outbox[0].to, [submission.user.email])
@@ -435,20 +451,20 @@ class TestEmailAdapter(AdapterMixin, TestCase):
     def test_no_email_private_comment(self):
         comment = CommentFactory(internal=True)
 
-        self.adapter_process(MESSAGES.COMMENT, related=comment, submission=comment.submission)
+        self.adapter_process(MESSAGES.COMMENT, related=comment, source=comment.source)
         self.assertEqual(len(mail.outbox), 0)
 
     def test_no_email_own_comment(self):
         application = ApplicationSubmissionFactory()
-        comment = CommentFactory(user=application.user, submission=application)
+        comment = CommentFactory(user=application.user, source=application)
 
-        self.adapter_process(MESSAGES.COMMENT, related=comment, user=comment.user, submission=comment.submission)
+        self.adapter_process(MESSAGES.COMMENT, related=comment, user=comment.user, source=comment.source)
         self.assertEqual(len(mail.outbox), 0)
 
     def test_reviewers_email(self):
         reviewers = ReviewerFactory.create_batch(4)
         submission = ApplicationSubmissionFactory(status='external_review', reviewers=reviewers, workflow_stages=2)
-        self.adapter_process(MESSAGES.READY_FOR_REVIEW, submission=submission)
+        self.adapter_process(MESSAGES.READY_FOR_REVIEW, source=submission)
 
         self.assertEqual(len(mail.outbox), 4)
         self.assertTrue(mail.outbox[0].subject, 'ready to review')
@@ -492,7 +508,7 @@ class TestAnyMailBehaviour(AdapterMixin, TestCase):
 
     def test_email_new_submission(self):
         submission = ApplicationSubmissionFactory()
-        self.adapter_process(MESSAGES.NEW_SUBMISSION, submission=submission)
+        self.adapter_process(MESSAGES.NEW_SUBMISSION, source=submission)
 
         self.assertEqual(len(mail.outbox), 1)
         self.assertEqual(mail.outbox[0].to, [submission.user.email])
@@ -536,3 +552,93 @@ class TestAnyMailBehaviour(AdapterMixin, TestCase):
         message.refresh_from_db()
         self.assertTrue('rejected' in message.status)
         self.assertTrue('spam' in message.status)
+
+
+class TestAdaptersForProject(AdapterMixin, TestCase):
+    slack = SlackAdapter
+    activity = ActivityAdapter
+    source_factory = ProjectFactory
+    # Slack
+    target_url = 'https://my-slack-backend.com/incoming/my-very-secret-key'
+    target_room = '#<ROOM ID>'
+
+    def test_activity_lead_change(self):
+        old_lead = UserFactory()
+        project = self.source_factory()
+        self.adapter_process(
+            MESSAGES.UPDATE_PROJECT_LEAD,
+            adapter=self.activity(),
+            source=project,
+            related=old_lead,
+        )
+        self.assertEqual(Activity.objects.count(), 1)
+        activity = Activity.objects.first()
+        self.assertIn(str(old_lead), activity.message)
+        self.assertIn(str(project.lead), activity.message)
+
+    def test_activity_lead_change_from_none(self):
+        project = self.source_factory()
+        self.adapter_process(
+            MESSAGES.UPDATE_PROJECT_LEAD,
+            adapter=self.activity(),
+            source=project,
+            related='Unassigned',
+        )
+        self.assertEqual(Activity.objects.count(), 1)
+        activity = Activity.objects.first()
+        self.assertIn(str('Unassigned'), activity.message)
+        self.assertIn(str(project.lead), activity.message)
+
+    def test_activity_created(self):
+        project = self.source_factory()
+        self.adapter_process(
+            MESSAGES.CREATED_PROJECT,
+            adapter=self.activity(),
+            source=project,
+            related=project.submission,
+        )
+        self.assertEqual(Activity.objects.count(), 1)
+        activity = Activity.objects.first()
+        self.assertEqual(None, activity.related_object)
+
+    @override_settings(
+        SLACK_DESTINATION_URL=target_url,
+        SLACK_DESTINATION_ROOM=target_room,
+    )
+    @responses.activate
+    def test_slack_created(self):
+        responses.add(responses.POST, self.target_url, status=200, body='OK')
+        project = self.source_factory()
+        user = UserFactory()
+        self.adapter_process(
+            MESSAGES.CREATED_PROJECT,
+            adapter=self.slack(),
+            user=user,
+            source=project,
+            related=project.submission,
+        )
+        self.assertEqual(len(responses.calls), 1)
+        data = json.loads(responses.calls[0].request.body)
+        self.assertIn(str(user), data['message'])
+        self.assertIn(str(project), data['message'])
+
+    @override_settings(
+        SLACK_DESTINATION_URL=target_url,
+        SLACK_DESTINATION_ROOM=target_room,
+    )
+    @responses.activate
+    def test_slack_lead_change(self):
+        responses.add(responses.POST, self.target_url, status=200, body='OK')
+        project = self.source_factory()
+        user = UserFactory()
+        self.adapter_process(
+            MESSAGES.UPDATE_PROJECT_LEAD,
+            adapter=self.slack(),
+            user=user,
+            source=project,
+            related=project.submission,
+        )
+        self.assertEqual(len(responses.calls), 1)
+        data = json.loads(responses.calls[0].request.body)
+        self.assertIn(str(user), data['message'])
+        self.assertIn(str(project), data['message'])
diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py
index 25b216107e5528f0eaa36aaaad3f95c950b97b93..c0edaf6b7abe782220d0d241b14e1e2eeddfb64d 100644
--- a/opentech/apply/activity/views.py
+++ b/opentech/apply/activity/views.py
@@ -14,17 +14,20 @@ ACTIVITY_LIMIT = 50
 class AllActivityContextMixin:
     def get_context_data(self, **kwargs):
         extra = {
-            'actions': Activity.actions.filter(submission__in=self.object_list).select_related(
-                'submission',
+            'actions': Activity.actions.select_related(
                 'user',
+            ).prefetch_related(
+                'source',
             )[:ACTIVITY_LIMIT],
-            'comments': Activity.comments.filter(submission__in=self.object_list).select_related(
-                'submission',
+            'comments': Activity.comments.select_related(
                 'user',
+            ).prefetch_related(
+                'source',
             )[:ACTIVITY_LIMIT],
-            'all_activity': Activity.objects.filter(submission__in=self.object_list).select_related(
-                'submission',
+            'all_activity': Activity.objects.select_related(
                 'user',
+            ).prefetch_related(
+                'source',
             )[:ACTIVITY_LIMIT],
         }
         return super().get_context_data(**extra, **kwargs)
@@ -32,15 +35,17 @@ class AllActivityContextMixin:
 
 class ActivityContextMixin:
     def get_context_data(self, **kwargs):
+        related_query = self.model.activities.rel.related_query_name
+        query = {related_query: self.object}
         extra = {
             # Do not prefetch on the related_object__author as the models
             # are not homogeneous and this will fail
-            'actions': Activity.actions.filter(submission=self.object).select_related(
+            'actions': Activity.actions.filter(**query).select_related(
                 'user',
             ).prefetch_related(
                 'related_object__submission',
             ).visible_to(self.request.user),
-            'comments': Activity.comments.filter(submission=self.object).select_related(
+            'comments': Activity.comments.filter(**query).select_related(
                 'user',
             ).prefetch_related(
                 'related_object__submission',
@@ -54,8 +59,9 @@ class CommentFormView(DelegatedViewMixin, CreateView):
     context_name = 'comment_form'
 
     def form_valid(self, form):
+        source = self.kwargs['object']
         form.instance.user = self.request.user
-        form.instance.submission = self.kwargs['submission']
+        form.instance.source = source
         form.instance.type = COMMENT
         form.instance.timestamp = timezone.now()
         response = super().form_valid(form)
@@ -63,13 +69,13 @@ class CommentFormView(DelegatedViewMixin, CreateView):
             MESSAGES.COMMENT,
             request=self.request,
             user=self.request.user,
-            submission=self.object.submission,
+            source=source,
             related=self.object,
         )
         return response
 
     def get_success_url(self):
-        return self.object.submission.get_absolute_url() + '#communications'
+        return self.object.source.get_absolute_url() + '#communications'
 
     @classmethod
     def contribute_form(cls, instance, user):
diff --git a/opentech/apply/dashboard/templates/dashboard/dashboard.html b/opentech/apply/dashboard/templates/dashboard/dashboard.html
index 5b0b059bd62e8666a9e0e0273986f71e16957d1e..f4c6282fb88d132ddd9545605d1ad19249b180ec 100644
--- a/opentech/apply/dashboard/templates/dashboard/dashboard.html
+++ b/opentech/apply/dashboard/templates/dashboard/dashboard.html
@@ -22,6 +22,29 @@
 </div>
 <div class="wrapper wrapper--large wrapper--inner-space-medium">
 
+
+    <!-- Example Stat Block markup
+        <div class="wrapper wrapper--bottom-space">
+            <div class="stat-block">
+                <a href="#" class="stat-block__item">
+                    <p class="stat-block__number">3</p>
+                    <p class="stat-block__text">Submissions waiting for your review</p>
+                    <div class="stat-block__view">View</div>
+                </a>
+                <a href="#" class="stat-block__item">
+                    <p class="stat-block__number">10</p>
+                    <p class="stat-block__text">Live projects under your management</p>
+                    <div class="stat-block__view">View</div>
+                </a>
+                <a href="#" class="stat-block__item">
+                    <p class="stat-block__number">4</p>
+                    <p class="stat-block__text">Requests for payment requiring your attention</p>
+                    <div class="stat-block__view">View</div>
+                </a>
+            </div>
+        </div>
+    -->
+
     <div class="wrapper wrapper--bottom-space">
         {% include "dashboard/includes/waiting-for-review.html" with in_review_count=in_review_count my_review=my_review display_more=display_more active_statuses_filter=active_statuses_filter %}
     </div>
diff --git a/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html b/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html
index 1e5731de0e367c927aa2607f3a5e28d8dcf49ed9..28d896f8873eb9a15a615eedafd67607542f35a1 100644
--- a/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html
+++ b/opentech/apply/determinations/templates/determinations/includes/batch_determination_confirmation.html
@@ -1,4 +1,4 @@
-<div class="modal modal--secondary" id="batch-send-determination">
+<div class="modal" id="batch-send-determination">
 <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Send determination</h4>
 <div class="modal__copy">
     <p>This determination message will be emailed to {{ count }} applicants and <strong>cannot be undone.</strong></p>
diff --git a/opentech/apply/determinations/tests/test_views.py b/opentech/apply/determinations/tests/test_views.py
index ffb9486fb40840569d5ff05bc4507215dd56d71e..03ead29bd2675e75e9b32f1a4e4f0b60509c6aa3 100644
--- a/opentech/apply/determinations/tests/test_views.py
+++ b/opentech/apply/determinations/tests/test_views.py
@@ -2,7 +2,7 @@ import urllib
 
 from django.contrib.messages.storage.fallback import FallbackStorage
 from django.contrib.sessions.middleware import SessionMiddleware
-from django.test import RequestFactory
+from django.test import override_settings, RequestFactory
 from django.urls import reverse_lazy
 
 from opentech.apply.activity.models import Activity
@@ -87,6 +87,7 @@ class DeterminationFormTestCase(BaseViewTestCase):
         response = self.get_page(submission, 'form')
         self.assertNotContains(response, 'Update ')
 
+    @override_settings(PROJECTS_ENABLED=False)
     def test_can_edit_draft_determination_if_not_lead(self):
         submission = ApplicationSubmissionFactory(status='in_discussion')
         determination = DeterminationFactory(submission=submission, author=self.user, accepted=True)
@@ -94,6 +95,13 @@ class DeterminationFormTestCase(BaseViewTestCase):
         self.assertContains(response, 'Approved')
         self.assertRedirects(response, self.absolute_url(submission.get_absolute_url()))
 
+    def test_can_edit_draft_determination_if_not_lead_with_projects(self):
+        submission = ApplicationSubmissionFactory(status='in_discussion')
+        determination = DeterminationFactory(submission=submission, author=self.user, accepted=True)
+        response = self.post_page(submission, {'data': 'value', 'outcome': determination.outcome}, 'form')
+        self.assertContains(response, 'Approved')
+        self.assertRedirects(response, self.absolute_url(submission.get_absolute_url()))
+
     def test_sends_message_if_requires_more_info(self):
         submission = ApplicationSubmissionFactory(status='in_discussion', lead=self.user)
         determination = DeterminationFactory(submission=submission, author=self.user)
@@ -125,6 +133,143 @@ class DeterminationFormTestCase(BaseViewTestCase):
         self.assertEqual(submission_original.status, 'invited_to_proposal')
         self.assertEqual(submission_next.status, 'draft_proposal')
 
+    def test_first_stage_accepted_determination_does_not_create_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='concept_review_discussion',
+            workflow_stages=2,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': ACCEPTED,
+            'message': 'You are invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        self.assertIsNotNone(submission_next)
+
+        # Confirm a Project was not created for either submission since it's
+        # not in the final stage of its workflow.
+        self.assertFalse(hasattr(submission_original, 'project'))
+        self.assertFalse(hasattr(submission_next, 'project'))
+
+    def test_first_stage_rejected_determination_does_not_create_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='concept_review_discussion',
+            workflow_stages=2,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': REJECTED,
+            'message': 'You are not invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        # There should be no next submission since rejected is an end to the
+        # applications flow.
+        self.assertIsNone(submission_next)
+
+        # Confirm a Project was not created for the original
+        # ApplicationSubmission.
+        self.assertFalse(hasattr(submission_original, 'project'))
+
+    def test_second_stage_accepted_determination_creates_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='proposal_determination',
+            workflow_stages=2,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': ACCEPTED,
+            'message': 'You are invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+        # import ipdb;ipdb.set_trace()
+
+        # There should be no next submission since rejected is an end to the
+        # applications flow.
+        self.assertIsNone(submission_next)
+
+        self.assertTrue(hasattr(submission_original, 'project'))
+        self.assertFalse(hasattr(submission_next, 'project'))
+
+    def test_second_stage_rejected_determination_does_not_create_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='proposal_determination',
+            workflow_stages=2,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': REJECTED,
+            'message': 'You are not invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        # There should be no next submission since rejected is an end to the
+        # applications flow.
+        self.assertIsNone(submission_next)
+
+        self.assertFalse(hasattr(submission_original, 'project'))
+
+    def test_single_stage_accepted_determination_creates_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='post_review_discussion',
+            workflow_stages=1,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': ACCEPTED,
+            'message': 'You are invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        self.assertIsNone(submission_next)
+        self.assertTrue(hasattr(submission_original, 'project'))
+
+    def test_single_stage_rejected_determination_does_not_create_project(self):
+        submission = ApplicationSubmissionFactory(
+            status='post_review_discussion',
+            workflow_stages=1,
+            lead=self.user,
+        )
+
+        self.post_page(submission, {
+            'data': 'value',
+            'outcome': REJECTED,
+            'message': 'You are not invited to submit a proposal',
+        }, 'form')
+
+        # Cant use refresh from DB with FSM
+        submission_original = self.refresh(submission)
+        submission_next = submission_original.next
+
+        self.assertIsNone(submission_next)
+        self.assertFalse(hasattr(submission_original, 'project'))
+
 
 class BatchDeterminationTestCase(BaseViewTestCase):
     user_factory = StaffFactory
diff --git a/opentech/apply/determinations/views.py b/opentech/apply/determinations/views.py
index a084214b17544cfcdbb9c9150d05852e2968fd7b..260e6eafa576650c947bf175e7f426cc37894507 100644
--- a/opentech/apply/determinations/views.py
+++ b/opentech/apply/determinations/views.py
@@ -3,6 +3,7 @@ from urllib import parse
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.core.exceptions import PermissionDenied
+from django.db import transaction
 from django.http import HttpResponseRedirect
 from django.shortcuts import get_object_or_404
 from django.urls import reverse_lazy
@@ -15,6 +16,7 @@ from opentech.apply.activity.models import Activity
 from opentech.apply.activity.messaging import messenger, MESSAGES
 from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.funds.workflow import DETERMINATION_OUTCOMES
+from opentech.apply.projects.models import Project
 from opentech.apply.utils.views import CreateOrUpdateView, ViewDispatcher
 from opentech.apply.users.decorators import staff_required
 
@@ -118,7 +120,7 @@ class BatchDeterminationCreateView(CreateView):
             MESSAGES.BATCH_DETERMINATION_OUTCOME,
             request=self.request,
             user=self.request.user,
-            submissions=submissions.filter(id__in=list(determinations)),
+            sources=submissions.filter(id__in=list(determinations)),
             related=determinations,
         )
 
@@ -242,7 +244,10 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView):
     def form_valid(self, form):
         super().form_valid(form)
 
-        if not self.object.is_draft:
+        if self.object.is_draft:
+            return HttpResponseRedirect(self.submission.get_absolute_url())
+
+        with transaction.atomic():
             messenger(
                 MESSAGES.DETERMINATION_OUTCOME,
                 request=self.request,
@@ -251,6 +256,7 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView):
                 related=self.object,
             )
             proposal_form = form.cleaned_data.get('proposal_form')
+
             transition = transition_from_outcome(form.cleaned_data.get('outcome'), self.submission)
 
             if self.object.outcome == NEEDS_MORE_INFO:
@@ -259,12 +265,36 @@ class DeterminationCreateOrUpdateView(CreateOrUpdateView):
                     message=self.object.stripped_message,
                     timestamp=timezone.now(),
                     user=self.request.user,
-                    submission=self.submission,
+                    source=self.submission,
                     related_object=self.object,
                 )
 
             self.submission.perform_transition(
-                transition, self.request.user, request=self.request, notify=False, proposal_form=proposal_form)
+                transition,
+                self.request.user,
+                request=self.request,
+                notify=False,
+                proposal_form=proposal_form,
+            )
+
+            if self.submission.accepted_for_funding:
+                project = Project.create_from_submission(self.submission)
+                if project:
+                    messenger(
+                        MESSAGES.CREATED_PROJECT,
+                        request=self.request,
+                        user=self.request.user,
+                        source=project,
+                        related=project.submission,
+                    )
+
+        messenger(
+            MESSAGES.DETERMINATION_OUTCOME,
+            request=self.request,
+            user=self.object.author,
+            source=self.object.submission,
+            related=self.object,
+        )
 
         return HttpResponseRedirect(self.submission.get_absolute_url())
 
diff --git a/opentech/apply/funds/api_views.py b/opentech/apply/funds/api_views.py
index bb406b79326dc578b2214530d77aea315bd79208..bb09838102064ee24e79d8147cc0f5226c147728 100644
--- a/opentech/apply/funds/api_views.py
+++ b/opentech/apply/funds/api_views.py
@@ -163,7 +163,7 @@ class CommentFilter(filters.FilterSet):
 
 class AllCommentFilter(CommentFilter):
     class Meta(CommentFilter.Meta):
-        fields = CommentFilter.Meta.fields + ['submission']
+        fields = CommentFilter.Meta.fields + ['source_object_id']
 
 
 class CommentList(generics.ListAPIView):
@@ -200,13 +200,13 @@ class CommentListCreate(generics.ListCreateAPIView):
             timestamp=timezone.now(),
             type=COMMENT,
             user=self.request.user,
-            submission_id=self.kwargs['pk']
+            source=ApplicationSubmission.objects.get(pk=self.kwargs['pk'])
         )
         messenger(
             MESSAGES.COMMENT,
             request=self.request,
             user=self.request.user,
-            submission=obj.submission,
+            source=obj.submission,
             related=obj,
         )
 
diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py
index bec9b723dbd973e0a47b979aa320d28387084fe9..c0cbb6e2d4a8da9fb74ad1df3df461d53343f3e1 100644
--- a/opentech/apply/funds/blocks.py
+++ b/opentech/apply/funds/blocks.py
@@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _
 
 from wagtail.core import blocks
 
-from addressfield.fields import AddressField
+from addressfield.fields import AddressField, ADDRESS_FIELDS_ORDER
 from opentech.apply.categories.blocks import CategoryQuestionBlock
 from opentech.apply.stream_forms.blocks import FormFieldsBlock
 from opentech.apply.utils.blocks import (
@@ -69,18 +69,15 @@ class AddressFieldBlock(ApplicationSingleIncludeFieldBlock):
         # Based on the fields listed in addressfields/widgets.py
         return ', '.join(
             data[field]
-            for field in order_fields
+            for field in ADDRESS_FIELDS_ORDER
             if data[field]
         )
 
     def prepare_data(self, value, data, serialize):
-        order_fields = [
-            'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country'
-        ]
         data = json.loads(data)
         data = {
             field: data[field]
-            for field in order_fields
+            for field in ADDRESS_FIELDS_ORDER
         }
 
         if serialize:
diff --git a/opentech/apply/funds/files.py b/opentech/apply/funds/files.py
new file mode 100644
index 0000000000000000000000000000000000000000..6f834375decea544af46540a69cd42b1370fae84
--- /dev/null
+++ b/opentech/apply/funds/files.py
@@ -0,0 +1,27 @@
+import os
+from django.urls import reverse
+
+from opentech.apply.stream_forms.files import StreamFieldFile
+
+
+def generate_submission_file_path(submission_id, field_id, file_name):
+    path = os.path.join('submission', str(submission_id), str(field_id))
+    if file_name.startswith(path):
+        return file_name
+
+    return os.path.join(path, file_name)
+
+
+class SubmissionStreamFileField(StreamFieldFile):
+    def generate_filename(self):
+        return generate_submission_file_path(self.instance.pk, self.field.id, self.name)
+
+    @property
+    def url(self):
+        return reverse(
+            'apply:submissions:serve_private_media', kwargs={
+                'pk': self.instance.pk,
+                'field_id': self.field.id,
+                'file_name': self.basename,
+            }
+        )
diff --git a/opentech/apply/funds/models/mixins.py b/opentech/apply/funds/models/mixins.py
index a0ca44d8b24a43835bf03d020c243c700cd93f9b..03c943107ea9354dad440de83d68a9b5ac8a96cb 100644
--- a/opentech/apply/funds/models/mixins.py
+++ b/opentech/apply/funds/models/mixins.py
@@ -1,7 +1,5 @@
-from django.conf import settings
 from django.utils.safestring import mark_safe
 from django.core.files import File
-from django.core.files.storage import get_storage_class
 
 from opentech.apply.stream_forms.blocks import (
     FileFieldBlock, FormFieldBlock, GroupToggleBlock, ImageFieldBlock, MultiFileFieldBlock
@@ -9,21 +7,13 @@ from opentech.apply.stream_forms.blocks import (
 from opentech.apply.utils.blocks import SingleIncludeMixin
 
 from opentech.apply.stream_forms.blocks import UploadableMediaBlock
-from opentech.apply.stream_forms.files import StreamFieldFile
+from opentech.apply.utils.storage import PrivateStorage
 
+from ..files import SubmissionStreamFileField
 
 __all__ = ['AccessFormData']
 
 
-private_file_storage = getattr(settings, 'PRIVATE_FILE_STORAGE', None)
-submission_storage_class = get_storage_class(private_file_storage)
-
-if private_file_storage:
-    submission_storage = submission_storage_class(is_submission=True)
-else:
-    submission_storage = submission_storage_class()
-
-
 class UnusedFieldException(Exception):
     pass
 
@@ -34,8 +24,9 @@ class AccessFormData:
     requires:
          - form_data > jsonfield containing the submitted data
          - form_fields > streamfield containing the original form fields
-
     """
+    stream_file_class = SubmissionStreamFileField
+    storage_class = PrivateStorage
 
     @property
     def raw_data(self):
@@ -54,46 +45,51 @@ class AccessFormData:
         return data
 
     @classmethod
-    def stream_file(cls, file):
+    def stream_file(cls, instance, field, file):
         if not file:
             return []
-        if isinstance(file, StreamFieldFile):
+        if isinstance(file, cls.stream_file_class):
             return file
         if isinstance(file, File):
-            return StreamFieldFile(file, name=file.name, storage=submission_storage)
+            return cls.stream_file_class(instance, field, file, name=file.name, storage=cls.storage_class())
 
         # This fixes a backwards compatibility issue with #507
         # Once every application has been re-saved it should be possible to remove it
         if 'path' in file:
             file['filename'] = file['name']
             file['name'] = file['path']
-        return StreamFieldFile(None, name=file['name'], filename=file.get('filename'), storage=submission_storage)
+        return cls.stream_file_class(instance, field, None, name=file['name'], filename=file.get('filename'), storage=cls.storage_class())
 
     @classmethod
-    def process_file(cls, file):
+    def process_file(cls, instance, field, file):
         if isinstance(file, list):
-            return [cls.stream_file(f) for f in file]
+            return [cls.stream_file(instance, field, f) for f in file]
         else:
-            return cls.stream_file(file)
+            return cls.stream_file(instance, field, file)
 
     @classmethod
     def from_db(cls, db, field_names, values):
         instance = super().from_db(db, field_names, values)
         if 'form_data' in field_names:
             # When the form_data is loaded from the DB deserialise it
-            instance.form_data = cls.deserialised_data(instance.form_data, instance.form_fields)
+            instance.form_data = cls.deserialised_data(instance, instance.form_data, instance.form_fields)
         return instance
 
     @classmethod
-    def deserialised_data(cls, data, form_fields):
+    def deserialised_data(cls, instance, data, form_fields):
         # Converts the file dicts into actual file objects
         data = data.copy()
-        for field in form_fields.stream_data:
-            block = form_fields.stream_block.child_blocks[field['type']]
+        # PERFORMANCE NOTE:
+        # Do not attempt to iterate over form_fields - that will fully instantiate the form_fields
+        # including any sub queries that they do
+        for i, field_data in enumerate(form_fields.stream_data):
+            block = form_fields.stream_block.child_blocks[field_data['type']]
             if isinstance(block, UploadableMediaBlock):
-                field_id = field.get('id')
-                file = data.get(field_id, [])
-                data[field_id] = cls.process_file(file)
+                field_id = field_data.get('id')
+                if field_id:
+                    field = form_fields[i]
+                    file = data.get(field_id, [])
+                    data[field_id] = cls.process_file(instance, field, file)
         return data
 
     def get_definitive_id(self, id):
@@ -123,17 +119,25 @@ class AccessFormData:
                 yield field_id
 
     @property
-    def question_text_field_ids(self):
+    def file_field_ids(self):
         for field_id, field in self.fields.items():
             if isinstance(field.block, (FileFieldBlock, ImageFieldBlock, MultiFileFieldBlock)):
+                yield field_id
+
+    @property
+    def question_text_field_ids(self):
+        file_fields = list(self.file_field_ids)
+        for field_id, field in self.fields.items():
+            if field_id in file_fields:
                 pass
             elif isinstance(field.block, FormFieldBlock):
                 yield field_id
 
     @property
     def first_group_question_text_field_ids(self):
+        file_fields = list(self.file_field_ids)
         for field_id, field in self.fields.items():
-            if isinstance(field.block, (FileFieldBlock, ImageFieldBlock, MultiFileFieldBlock)):
+            if field_id in file_fields:
                 continue
             elif isinstance(field.block, GroupToggleBlock):
                 break
diff --git a/opentech/apply/funds/models/submissions.py b/opentech/apply/funds/models/submissions.py
index a932968d7d56ff5702ee9d93ae21f62c8c4f98a2..d20669b5692dfd6769156d559799fa6078e82672 100644
--- a/opentech/apply/funds/models/submissions.py
+++ b/opentech/apply/funds/models/submissions.py
@@ -1,7 +1,7 @@
-import os
 from functools import partialmethod
 
 from django.conf import settings
+from django.contrib.contenttypes.fields import GenericRelation
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
 from django.contrib.postgres.fields import JSONField
@@ -59,6 +59,7 @@ from ..workflow import (
     get_review_active_statuses,
     INITIAL_STATE,
     PHASES,
+    PHASES_MAPPING,
     review_statuses,
     STAGE_CHANGE_ACTIONS,
     UserPermissions,
@@ -140,7 +141,7 @@ class ApplicationSubmissionQueryset(JSONOrderable):
         return self.exclude(next__isnull=False)
 
     def with_latest_update(self):
-        activities = self.model.activities.field.model
+        activities = self.model.activities.rel.model
         latest_activity = activities.objects.filter(submission=OuterRef('id')).select_related('user')
         return self.annotate(
             last_user_update=Subquery(latest_activity[:1].values('user__full_name')),
@@ -148,7 +149,7 @@ class ApplicationSubmissionQueryset(JSONOrderable):
         )
 
     def for_table(self, user):
-        activities = self.model.activities.field.model
+        activities = self.model.activities.rel.model
         comments = activities.comments.filter(submission=OuterRef('id')).visible_to(user)
         roles_for_review = self.model.assigned.field.model.objects.with_roles().filter(
             submission=OuterRef('id'), reviewer=user)
@@ -199,8 +200,10 @@ class ApplicationSubmissionQueryset(JSONOrderable):
         ).prefetch_related(
             Prefetch(
                 'assigned',
-                queryset=AssignedReviewers.objects.reviewed().review_order().prefetch_related(
-                    Prefetch('opinions', queryset=ReviewOpinion.objects.select_related('author__reviewer'))
+                queryset=AssignedReviewers.objects.reviewed().review_order().select_related(
+                    'reviewer',
+                ).prefetch_related(
+                    Prefetch('review__opinions', queryset=ReviewOpinion.objects.select_related('author')),
                 ),
                 to_attr='has_reviewed'
             ),
@@ -209,7 +212,6 @@ class ApplicationSubmissionQueryset(JSONOrderable):
                 queryset=AssignedReviewers.objects.not_reviewed().staff(),
                 to_attr='hasnt_reviewed'
             )
-
         ).select_related(
             'page',
             'round',
@@ -217,6 +219,7 @@ class ApplicationSubmissionQueryset(JSONOrderable):
             'user',
             'previous__page',
             'previous__round',
+            'previous__lead',
         )
 
 
@@ -396,6 +399,12 @@ class ApplicationSubmission(
         related_name='submissions',
         blank=True,
     )
+    activities = GenericRelation(
+        'activity.Activity',
+        content_type_field='source_content_type',
+        object_id_field='source_object_id',
+        related_query_name='submission',
+    )
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
     search_data = models.TextField()
 
@@ -532,7 +541,7 @@ class ApplicationSubmission(
 
     def from_draft(self):
         self.is_draft = True
-        self.form_data = self.deserialised_data(self.draft_revision.form_data, self.form_fields)
+        self.form_data = self.deserialised_data(self, self.draft_revision.form_data, self.form_fields)
         return self
 
     def create_revision(self, draft=False, force=False, by=None, **kwargs):
@@ -581,13 +590,12 @@ class ApplicationSubmission(
     def process_file_data(self, data):
         for field in self.form_fields:
             if isinstance(field.block, UploadableMediaBlock):
-                file = self.process_file(data.get(field.id, []))
-                folder = os.path.join('submission', str(self.id), field.id)
+                file = self.process_file(self, field, data.get(field.id, []))
                 try:
-                    file.save(folder)
+                    file.save()
                 except AttributeError:
                     for f in file:
-                        f.save(folder)
+                        f.save()
                 self.form_data[field.id] = file
 
     def save(self, *args, update_fields=list(), **kwargs):
@@ -703,6 +711,22 @@ class ApplicationSubmission(
     def __repr__(self):
         return f'<{self.__class__.__name__}: {self.user}, {self.round}, {self.page}>'
 
+    @property
+    def accepted_for_funding(self):
+        accepted = self.status in PHASES_MAPPING['accepted']['statuses']
+        return self.in_final_stage and accepted
+
+    @property
+    def in_final_stage(self):
+        stages = self.workflow.stages
+
+        stage_index = stages.index(self.stage)
+
+        # adjust the index since list.index() is zero-based
+        adjusted_index = stage_index + 1
+
+        return adjusted_index == len(stages)
+
     # Methods for accessing data on the submission
 
     def get_data(self):
@@ -736,7 +760,7 @@ def log_status_update(sender, **kwargs):
             MESSAGES.TRANSITION,
             user=by,
             request=request,
-            submission=instance,
+            source=instance,
             related=old_phase,
         )
 
@@ -745,7 +769,7 @@ def log_status_update(sender, **kwargs):
                 MESSAGES.READY_FOR_REVIEW,
                 user=by,
                 request=request,
-                submission=instance,
+                source=instance,
             )
 
     if instance.status in STAGE_CHANGE_ACTIONS:
@@ -753,7 +777,7 @@ def log_status_update(sender, **kwargs):
             MESSAGES.INVITED_TO_PROPOSAL,
             request=request,
             user=by,
-            submission=instance,
+            source=instance,
         )
 
 
diff --git a/opentech/apply/funds/models/utils.py b/opentech/apply/funds/models/utils.py
index c5131399e0b82001dccd59200314786e91e029bc..582a1df10f2682a4de9fe9b50c3dc542b94af5f7 100644
--- a/opentech/apply/funds/models/utils.py
+++ b/opentech/apply/funds/models/utils.py
@@ -97,7 +97,7 @@ class WorkflowStreamForm(WorkflowHelpers, AbstractStreamForm):  # type: ignore
             MESSAGES.NEW_SUBMISSION,
             request=request,
             user=form_submission.user,
-            submission=form_submission,
+            source=form_submission,
         )
         return super().render_landing_page(request, form_submission=None, *args, **kwargs)
 
diff --git a/opentech/apply/funds/serializers.py b/opentech/apply/funds/serializers.py
index 54478823b417cab8b1a3c054b7fac2f6a3d0a0a7..cd828247a3e32144b6a4d6066e2f76d7de0b8b80 100644
--- a/opentech/apply/funds/serializers.py
+++ b/opentech/apply/funds/serializers.py
@@ -208,7 +208,7 @@ class CommentSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Activity
-        fields = ('id', 'timestamp', 'user', 'submission', 'message', 'visibility', 'edited', 'edit_url', 'editable')
+        fields = ('id', 'timestamp', 'user', 'source', 'message', 'visibility', 'edited', 'edit_url', 'editable')
 
     def get_message(self, obj):
         return bleach_value(markdown(obj.message))
diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
index 2e9404be49b4089f9dfbbdd25485af922ecc6d31..4bea7c49f804685720f6b8de0ccd94ec03193d06 100644
--- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
+++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html
@@ -6,18 +6,26 @@
         {{ reviewer_form.media.css }}
 {% endblock %}
 
-{% block admin_actions %}
-    {% include "funds/includes/actions.html" with mobile=True %}
+{% block mobile_actions %}
+    <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a>
+    <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions sidebar__inner--mobile">
+        {% include "funds/includes/actions.html"  %}
+    </div>
 {% endblock %}
 
-{% block sidebar_forms %}
+{% block sidebar_top %}
+    <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}">
         {% include "funds/includes/actions.html" %}
-        {% include "funds/includes/screening_form.html" %}
-        {% include "funds/includes/progress_form.html" %}
-        {% include "funds/includes/update_lead_form.html" %}
-        {% include "funds/includes/update_reviewer_form.html" %}
-        {% include "funds/includes/update_partner_form.html" %}
-        {% include "funds/includes/update_meta_categories_form.html" %}
+    </div>
+    {% include "funds/includes/screening_form.html" %}
+    {% include "funds/includes/progress_form.html" %}
+    {% include "funds/includes/update_lead_form.html" %}
+    {% include "funds/includes/update_reviewer_form.html" %}
+    {% include "funds/includes/update_partner_form.html" %}
+    {% if PROJECTS_ENABLED %}
+        {% include "funds/includes/create_project_form.html" %}
+    {% endif %}
+    {% include "funds/includes/update_meta_categories_form.html" %}
 {% endblock %}
 
 {% block reviews %}
@@ -61,4 +69,6 @@
     <script src="{% static 'js/apply/submission-text-cleanup.js' %}"></script>
     <script src="{% static 'js/apply/toggle-related.js' %}"></script>
     <script src="{% static 'js/apply/edit-comment.js' %}"></script>
+    <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script>
+    <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script>
 {% endblock %}
diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html
index dd63c6757300488f5209cd59860f772af197e0a2..7c997b86d74e10ee61053915661e78f5ae7a6f8e 100644
--- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html
+++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html
@@ -53,7 +53,7 @@
 <div class="wrapper wrapper--large wrapper--tabs js-tabs-content">
 {# Tab 1 #}
     <div class="tabs__content" id="tab-1">
-        {% block admin_actions %}
+        {% block mobile_actions %}
         {% endblock %}
         <div class="wrapper wrapper--sidebar">
             {% if request.user|has_edit_perm:object and object.status == 'draft_proposal' and not request.user.is_apply_staff %}
@@ -67,45 +67,48 @@
                     <header class="heading heading--submission-meta heading-text zeta">
                         <span>Submitted: <strong>{{ object.submit_time.date }} by {{ object.user.get_full_name }}</strong></span>
                         <span>Last edited: <strong>{{ object.live_revision.timestamp.date }} by {{ object.live_revision.author }}</strong></span>
-                        {% if perms.funds.delete_applicationsubmission %}
-                            <a class="link link--delete-submission is-active" href="{% url 'funds:submissions:delete' object.id %}">
-                                Delete
-                                <svg class="icon icon--delete"><use xlink:href="#delete"></use></svg>
-                            </a>
-                        {% endif %}
-                        {% if request.user|has_edit_perm:object %}
-                            <a class="link link--edit-submission is-active" href="{% url 'funds:submissions:edit' object.id %}">
-                                Edit
-                                <svg class="icon icon--pen"><use xlink:href="#pen"></use></svg>
-                            </a>
-                        {% else %}
-                            <span class="link link--edit-submission">
-                                Edit
-                                <svg class="icon icon--padlock"><use xlink:href="#padlock"></use></svg>
-                            </span>
-                        {% endif %}
+                        <div class="wrapper wrapper--submission-actions">
+                            {% if perms.funds.delete_applicationsubmission %}
+                                <a class="link link--delete-submission is-active" href="{% url 'funds:submissions:delete' object.id %}">
+                                    Delete
+                                    <svg class="icon icon--delete"><use xlink:href="#delete"></use></svg>
+                                </a>
+                            {% endif %}
+                            {% if request.user|has_edit_perm:object %}
+                                <a class="link link--edit-submission is-active" href="{% url 'funds:submissions:edit' object.id %}">
+                                    Edit
+                                    <svg class="icon icon--pen"><use xlink:href="#pen"></use></svg>
+                                </a>
+                            {% else %}
+                                <span class="link link--edit-submission">
+                                    Edit
+                                    <svg class="icon icon--padlock"><use xlink:href="#padlock"></use></svg>
+                                </span>
+                            {% endif %}
+                        </div>
                     </header>
 
                     {% include "funds/includes/rendered_answers.html" %}
+
                 </article>
             {% endif %}
 
             <aside class="sidebar">
-                {% if request.user.is_apply_staff %}
-                    {% block sidebar_forms %}
-                    {% endblock %}
+                {% block sidebar_top %}
+                {% endblock %}
+
+                {% if object.project and PROJECTS_ENABLED %}
+                    {% include 'funds/includes/project_block.html' %}
                 {% endif %}
 
                 {% block determination %}
                     {% include 'determinations/includes/applicant_determination_block.html' with submission=object %}
                 {% endblock %}
 
-                {% if request.user.is_apply_staff %}
-                    {% block screening_status %}
-                    {% endblock %}
-                    {% block meta_categories %}
-                    {% endblock %}
-                {% endif %}
+                {% block screening_status %}
+                {% endblock %}
+                {% block meta_categories %}
+                {% endblock %}
 
                 {% block reviews %}
                 {% endblock %}
diff --git a/opentech/apply/funds/templates/funds/email/confirmation.html b/opentech/apply/funds/templates/funds/email/confirmation.html
index b02d696032eedb44d160264209ae517455a3bb3b..b13a56bc529f5ed4a9572a04460150adb0fea82d 100644
--- a/opentech/apply/funds/templates/funds/email/confirmation.html
+++ b/opentech/apply/funds/templates/funds/email/confirmation.html
@@ -1,11 +1,11 @@
 {% extends "messages/email/base.html" %}
 
-{% block content %}{% with email_context=submission.page.specific %}We appreciate your {{ email_context.title }} application submission to the {{ ORG_LONG_NAME }}. We will review and reply to your submission as quickly as possible.
+{% block content %}{% with email_context=source.page.specific %}We appreciate your {{ email_context.title }} application submission to the {{ ORG_LONG_NAME }}. We will review and reply to your submission as quickly as possible.
 
 Our reply will have the next steps for your {{ email_context.title }} application. You can find more information about our support options, review process and selection criteria on our website.
 
 {{ email_context.confirmation_text_text }}{% endwith %}
 
-Project name: {{ submission.title }}
-Contact name: {{ submission.user.get_full_name }}
-Contact email: {{ submission.user.email }}{% endblock %}
+Project name: {{ source.title }}
+Contact name: {{ source.user.get_full_name }}
+Contact email: {{ source.user.email }}{% endblock %}
diff --git a/opentech/apply/funds/templates/funds/includes/actions.html b/opentech/apply/funds/templates/funds/includes/actions.html
index 7850e045346c675a8590b4ca63a894458a8593d4..cfd7665631b18b7448da129f75ce1fb059be85ec 100644
--- a/opentech/apply/funds/templates/funds/includes/actions.html
+++ b/opentech/apply/funds/templates/funds/includes/actions.html
@@ -1,24 +1,28 @@
-{% if mobile %}
-    <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a>
-{% endif %}
-
-<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}">
-    <h5>Actions to take</h5>
+<h5>Actions to take</h5>
 
-    <a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a>
 
-    <a data-fancybox data-src="#update-status" class="button button--primary button--full-width {% if progress_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Update status</a>
+{% if PROJECTS_ENABLED %}
+<a data-fancybox
+    data-src="#create-project"
+    class="button button--bottom-space button--primary button--full-width {% if object.accepted_for_funding and not object.project %}is-not-disabled{% else %}is-disabled{% endif %}"
+    href="#">
+    Create Project
+</a>
+{% endif %}
 
+<a data-fancybox data-src="#screen-application" class="button button--bottom-space button--primary button--full-width {% if screening_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Screen application</a>
 
-    <p class="sidebar__separator">Assign</p>
+<a data-fancybox data-src="#update-status" class="button button--primary button--full-width {% if progress_form.should_show %}is-not-disabled{% else %}is-disabled{% endif %}" href="#">Update status</a>
 
-    <div class="wrapper wrapper--sidebar-buttons">
-        <a data-fancybox data-src="#update-reviewers" class="button button--half-width button--white" href="#">Reviewers</a>
-        <a data-fancybox data-src="#update-partners" class="button button--half-width button--white" href="#">Partners</a>
-        <a data-fancybox data-src="#assign-lead" class="button button--half-width button--white" href="#">Lead</a>
-    </div>
 
-    <a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">Revisions</a>
+<p class="sidebar__separator">Assign</p>
 
-    <a data-fancybox data-src="#update-meta-categories" class="button button--white button--full-width button--bottom-space" href="#">Meta Categories</a>
+<div class="wrapper wrapper--sidebar-buttons">
+    <a data-fancybox data-src="#update-reviewers" class="button button--half-width button--white" href="#">Reviewers</a>
+    <a data-fancybox data-src="#update-partners" class="button button--half-width button--white" href="#">Partners</a>
+    <a data-fancybox data-src="#assign-lead" class="button button--half-width button--white" href="#">Lead</a>
 </div>
+
+<a class="button button--white button--full-width button--bottom-space" href="{% url 'funds:submissions:revisions:list' submission_pk=object.id %}">Revisions</a>
+
+<a data-fancybox data-src="#update-meta-categories" class="button button--white button--full-width button--bottom-space" href="#">Meta Categories</a>
diff --git a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html
index 59d98b3f1687e66f7d0c66937cd177b83013d4cb..7e02a8075d0dde0b2253bbe0dabfc65f11491add 100644
--- a/opentech/apply/funds/templates/funds/includes/batch_progress_form.html
+++ b/opentech/apply/funds/templates/funds/includes/batch_progress_form.html
@@ -1,4 +1,4 @@
-<div class="modal modal--secondary" id="batch-progress">
+<div class="modal" id="batch-progress">
     <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Update Status</h4>
     <div class="modal__list-item modal__list-item--meta" aria-live="polite">
         <span class="js-batch-title-count"></span>
diff --git a/opentech/apply/funds/templates/funds/includes/create_project_form.html b/opentech/apply/funds/templates/funds/includes/create_project_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..f7b10c87b7925af311aea81c128017fce5b1dddd
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/create_project_form.html
@@ -0,0 +1,4 @@
+<div class="modal" id="create-project">
+    <h4 class="modal__header-bar">Create Project</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=project_form value='Create'%}
+</div>
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..4ed3da58223d879cda16744876e5288eb8cad3bf 100644
--- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
+++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
@@ -1,10 +1,40 @@
 {% 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"
+    {% if action %}action="{{ action }}"{% endif %}
+    >
     {% csrf_token %}
-    <div class="form__item">
-        {{ form }}
-    </div>
 
-    <input class="button button--primary button--top-space" id="{{ form.name }}-submit" name="{{ form_prefix }}{{ form.name }}" type="submit" form="{{ form.name }}" value="{{ value }}">
+    {{ form.media }}
+
+    {% for field in form %}
+        {% if field.field %}
+            {% include "forms/includes/field.html" %}
+        {% else %}
+            {{ field }}
+        {% endif %}
+    {% endfor %}
+
+    {% if cancel %}
+    <button
+        type="button"
+        data-fancybox-close=""
+        class="button button--{% if invert %}primary{% else %}white{% endif %} button--top-space"
+        title="Close">
+        Cancel
+    </button>
+    {% endif %}
+
+    <input
+        class="button button--{% if invert %}white{% else %}primary{% endif %} button--top-space"
+        id="{{ form.name }}-submit"
+        name="{{ form_prefix }}{{ form.name }}"
+        type="submit"
+        form="{{ form.name }}"
+        value="{{ value }}"
+    >
 
 </form>
diff --git a/opentech/apply/funds/templates/funds/includes/funding_block.html b/opentech/apply/funds/templates/funds/includes/funding_block.html
new file mode 100644
index 0000000000000000000000000000000000000000..c507b379fe44078dfa14260ebca6ef8fcbeae13c
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/funding_block.html
@@ -0,0 +1,16 @@
+<ul class="funding-block">
+    <li class="funding-block__item">
+        <p class="funding-block__title">Fund total</p>
+        <p class="funding-block__standout">$50,000</p>
+    </li>
+    <li class="funding-block__item">
+        <p class="funding-block__title">Total paid</p>
+        <p class="funding-block__standout">$2,000</p>
+        <p class="funding-block__meta">(4%)</p>
+    </li>
+    <li class="funding-block__item">
+        <p class="funding-block__title">Awaiting payment</p>
+        <p class="funding-block__standout">$10,000</p>
+        <p class="funding-block__meta">(20%)</p>
+    </li>
+</ul>
diff --git a/opentech/apply/funds/templates/funds/includes/invoice_block.html b/opentech/apply/funds/templates/funds/includes/invoice_block.html
new file mode 100644
index 0000000000000000000000000000000000000000..fdec65d18fa88878fdde1b758cfa0b607ae6df68
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/invoice_block.html
@@ -0,0 +1,14 @@
+<ul class="invoice-block">
+    <li class="invoice-block__item">
+        <p class="invoice-block__title">Contract number</p>
+        <p class="invoice-block__meta">XXXXXX</p>
+    </li>
+    <li class="invoice-block__item">
+        <p class="invoice-block__title">Pay to</p>
+        <p class="invoice-block__meta">XXXXXX</p>
+    </li>
+    <li class="invoice-block__item">
+        <p class="invoice-block__title">Some details</p>
+        <p class="invoice-block__meta">XXXXXX</p>
+    </li>
+</ul>
diff --git a/opentech/apply/funds/templates/funds/includes/payment_requests.html b/opentech/apply/funds/templates/funds/includes/payment_requests.html
new file mode 100644
index 0000000000000000000000000000000000000000..d344d17f9539db00ce09052968fdeba6e3239905
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/payment_requests.html
@@ -0,0 +1,79 @@
+<div class="payment-block">
+    <div class="payment-block__header">
+        <p class="payment-block__title">Payment Requests</p>
+        <button class="payment-block__button button button--primary">Add Request</button>
+    </div>
+
+    <table class="payment-block__table">
+        <thead>
+            <tr>
+                <th class="payment-block__table-amount">Amount</th>
+                <th class="payment-block__table-status">Status</th>
+                <th class="payment-block__table-docs">Documents</th>
+                <th class="payment-block__table-update">Status</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr>
+                <td><span class="payment-block__mobile-label">Amount: </span>$10,000</td>
+                <td><span class="payment-block__mobile-label">Status: </span>Approved 1111-11-11</td>
+                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
+                <td>
+                    <span class="payment-block__mobile-label">Status:</span>
+                    <span class="payment-block__status">Approved</span>
+                    <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a>
+                </td>
+            </tr>
+            <tr>
+                <td><span class="payment-block__mobile-label">Amount: </span>$1,000</td>
+                <td><span class="payment-block__mobile-label">Status: </span>Approved 2222-11-11</td>
+                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
+                <td>
+                    <span class="payment-block__mobile-label">Status:</span>
+                    <span class="payment-block__status">Paid</span>
+                    <a data-fancybox data-src="#change-payment-status" class="payment-block__status-link" href="#">Change status</a>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+    <p class="payment-block__rejected"><a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a></p>
+
+    <table class="payment-block__table is-hidden js-payment-block-rejected-table">
+        <thead>
+            <tr>
+                <th class="payment-block__table-amount">Amount</th>
+                <th class="payment-block__table-status">Status</th>
+                <th class="payment-block__table-docs">Documents</th>
+                <th class="payment-block__table-update">Status</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr>
+                <td><span class="payment-block__mobile-label">Amount: </span>$14,000</td>
+                <td><span class="payment-block__mobile-label">Status: </span>Rejected 1111-11-11</td>
+                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
+                <td>
+                    <span class="payment-block__mobile-label">Status:</span>
+                    <span class="payment-block__status">Rejected</span>
+                </td>
+            </tr>
+            <tr>
+                <td><span class="payment-block__mobile-label">Amount: </span>$105,000</td>
+                <td><span class="payment-block__mobile-label">Status: </span>Rejected 2222-11-11</td>
+                <td><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
+                <td>
+                    <span class="payment-block__mobile-label">Status:</span>
+                    <span class="payment-block__status">Rejected</span>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+</div>
+
+<div class="modal" id="change-payment-status">
+    <h4 class="modal__header-bar">Change payment status</h4>
+    <div class="wrapper--outer-space-medium">
+        <p>Current status: <b>Approved</b></p>
+        {# {% include 'funds/includes/delegated_form_base.html' with form=some_form value='Update'%} #}
+    </div>
+</div>
diff --git a/opentech/apply/funds/templates/funds/includes/project_block.html b/opentech/apply/funds/templates/funds/includes/project_block.html
new file mode 100644
index 0000000000000000000000000000000000000000..31041e55557fd2a65f236696c83f15a95caddd94
--- /dev/null
+++ b/opentech/apply/funds/templates/funds/includes/project_block.html
@@ -0,0 +1,6 @@
+<div class="sidebar__inner">
+    <h5>Project</h5>
+    <p>
+        <a href="{% url 'apply:projects:detail' pk=object.project.id %}">{{ object.project.title }}</a>
+    </p>
+</div>
diff --git a/opentech/apply/funds/templates/funds/includes/rendered_answers.html b/opentech/apply/funds/templates/funds/includes/rendered_answers.html
index 301e1820b640b35948f92e4f315f31917e038c6a..78fee6416cde93229f9002b89e4aa3f1b35f4978 100644
--- a/opentech/apply/funds/templates/funds/includes/rendered_answers.html
+++ b/opentech/apply/funds/templates/funds/includes/rendered_answers.html
@@ -25,6 +25,7 @@
         {{ object.get_address_display }}
     </div>
 </div>
+
 <div class="rich-text rich-text--answers">
     {{ object.output_answers }}
 </div>
diff --git a/opentech/apply/funds/templates/funds/submissions.html b/opentech/apply/funds/templates/funds/submissions.html
index ca70880476eaac1808566d08c7b7a7c21e54ef21..353450fa78d785a9339a12023d7eb3f69c5f3736 100644
--- a/opentech/apply/funds/templates/funds/submissions.html
+++ b/opentech/apply/funds/templates/funds/submissions.html
@@ -14,11 +14,6 @@
 </div>
 
 <div class="wrapper wrapper--large wrapper--inner-space-medium">
-
-    {% if closed_rounds or open_rounds %}
-        {% include "funds/includes/round-block.html" with closed_rounds=closed_rounds open_rounds=open_rounds title=rounds_title %}
-    {% endif %}
-
     {% block table %}
         {{ block.super }}
     {% endblock %}
diff --git a/opentech/apply/funds/tests/test_models.py b/opentech/apply/funds/tests/test_models.py
index eaa22338c1c81d12aabff0d25395dc18916a5c1f..a09447647fddc3965fcf11aa067f931a308a69f7 100644
--- a/opentech/apply/funds/tests/test_models.py
+++ b/opentech/apply/funds/tests/test_models.py
@@ -8,6 +8,7 @@ from django.conf import settings
 from django.core import mail
 from django.core.exceptions import ValidationError
 from django.test import TestCase, override_settings
+from django.urls import reverse
 
 from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.funds.blocks import EmailBlock, FullNameBlock
@@ -22,6 +23,7 @@ from .factories import (
     AssignedReviewersFactory,
     CustomFormFieldsFactory,
     FundTypeFactory,
+    InvitedToProposalFactory,
     LabFactory,
     RequestForPartnersFactory,
     RoundFactory,
@@ -452,11 +454,19 @@ class TestApplicationSubmission(TestCase):
         submission.create_revision(draft=True)
         self.assertEqual(submission.revisions.count(), 2)
 
+    def test_in_final_stage(self):
+        submission = InvitedToProposalFactory().previous
+        self.assertFalse(submission.in_final_stage)
 
+        submission = InvitedToProposalFactory()
+        self.assertTrue(submission.in_final_stage)
+
+
+@override_settings(ROOT_URLCONF='opentech.apply.urls')
 class TestSubmissionRenderMethods(TestCase):
     def test_named_blocks_not_included_in_answers(self):
         submission = ApplicationSubmissionFactory()
-        answers = submission.render_answers()
+        answers = submission.output_answers()
         for name in submission.named_blocks:
             field = submission.field(name)
             self.assertNotIn(field.value['field_label'], answers)
@@ -474,7 +484,7 @@ class TestSubmissionRenderMethods(TestCase):
         submission = ApplicationSubmissionFactory(
             form_fields__text_markup__value=rich_text_label
         )
-        answers = submission.render_answers()
+        answers = submission.output_answers()
         self.assertNotIn(rich_text_label, answers)
 
     def test_named_blocks_dont_break_if_no_response(self):
@@ -484,6 +494,28 @@ class TestSubmissionRenderMethods(TestCase):
         self.assertTrue('value' not in submission.raw_data)
         self.assertTrue('duration' in submission.raw_data)
 
+    def test_file_private_url_included(self):
+        submission = ApplicationSubmissionFactory()
+        answers = submission.output_answers()
+        for file_id in submission.file_field_ids:
+
+            def file_url_in_answers(file_to_test):
+                url = reverse(
+                    'apply:submissions:serve_private_media', kwargs={
+                        'pk': submission.pk,
+                        'field_id': file_id,
+                        'file_name': file_to_test.basename,
+                    }
+                )
+                self.assertIn(url, answers)
+
+            file_response = submission.data(file_id)
+            if isinstance(file_response, list):
+                for stream_file in file_response:
+                    file_url_in_answers(stream_file)
+            else:
+                file_url_in_answers(file_response)
+
 
 class TestRequestForPartners(TestCase):
     def test_message_when_no_round(self):
@@ -581,7 +613,7 @@ class TestForTableQueryset(TestCase):
 
         ReviewFactory(submission=submission_two)
 
-        qs = ApplicationSubmission.objects.for_table(user=staff)
+        qs = ApplicationSubmission.objects.order_by('pk').for_table(user=staff)
         submission = qs[0]
         self.assertEqual(submission, submission_one)
         self.assertEqual(submission.opinion_disagree, 1)
diff --git a/opentech/apply/funds/tests/test_views.py b/opentech/apply/funds/tests/test_views.py
index a51902eb5669be0ce1c1580b80ac7d719277370e..b5a7b0235a4270049372f36b0ff003a134e214a1 100644
--- a/opentech/apply/funds/tests/test_views.py
+++ b/opentech/apply/funds/tests/test_views.py
@@ -1,10 +1,13 @@
 from datetime import timedelta
 import json
 
+from django.contrib.auth.models import AnonymousUser
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.text import slugify
 
 from opentech.apply.activity.models import Activity, TEAM
+from opentech.apply.projects.models import Project
 from opentech.apply.determinations.tests.factories import DeterminationFactory
 from opentech.apply.funds.tests.factories import (
     ApplicationSubmissionFactory,
@@ -29,7 +32,7 @@ from opentech.apply.users.tests.factories import (
 from opentech.apply.utils.testing import make_request
 from opentech.apply.utils.testing.tests import BaseViewTestCase
 
-from ..models import ApplicationRevision
+from ..models import ApplicationRevision, ApplicationSubmission
 
 
 def prepare_form_data(submission, **kwargs):
@@ -198,6 +201,22 @@ class TestStaffSubmissionView(BaseSubmissionViewTestCase):
         response = self.get_page(self.submission)
         self.assertContains(response, 'Screening Status')
 
+    def test_can_create_project(self):
+        # check submission doesn't already have a Project
+        with self.assertRaisesMessage(Project.DoesNotExist, 'ApplicationSubmission has no project.'):
+            self.submission.project
+
+        self.post_page(self.submission, {
+            'form-submitted-project_form': '',
+            'submission': self.submission.id,
+        })
+
+        project = Project.objects.order_by('-pk').first()
+        submission = ApplicationSubmission.objects.get(pk=self.submission.pk)
+
+        self.assertTrue(hasattr(submission, 'project'))
+        self.assertEquals(submission.project.id, project.id)
+
 
 class TestReviewersUpdateView(BaseSubmissionViewTestCase):
     user_factory = StaffFactory
@@ -641,3 +660,56 @@ class TestSuperUserSubmissionView(BaseSubmissionViewTestCase):
         # Check that an activity was created that should only be viewable internally
         activity = Activity.objects.filter(message__contains='Screening status').first()
         self.assertEqual(activity.visibility, TEAM)
+
+
+class BaseSubmissionFileViewTestCase(BaseViewTestCase):
+    url_name = 'funds:submissions:{}'
+    base_view_name = 'serve_private_media'
+
+    def get_kwargs(self, instance):
+        document_fields = list(instance.file_field_ids)
+        field_id = document_fields[0]
+        document = instance.data(field_id)
+        return {
+            'pk': instance.pk,
+            'field_id': field_id,
+            'file_name': document.basename,
+        }
+
+
+class TestStaffSubmissionFileView(BaseSubmissionFileViewTestCase):
+    user_factory = StaffFactory
+
+    def test_staff_can_access(self):
+        submission = ApplicationSubmissionFactory()
+        response = self.get_page(submission)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestUserSubmissionFileView(BaseSubmissionFileViewTestCase):
+    user_factory = ApplicantFactory
+
+    def test_owner_can_access(self):
+        submission = ApplicationSubmissionFactory(user=self.user)
+        response = self.get_page(submission)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_user_can_not_access(self):
+        submission = ApplicationSubmissionFactory()
+        response = self.get_page(submission)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestAnonSubmissionFileView(BaseSubmissionFileViewTestCase):
+    user_factory = AnonymousUser
+
+    def test_anonymous_can_not_access(self):
+        submission = ApplicationSubmissionFactory()
+        response = self.get_page(submission)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 2)
+        for path, _ in response.redirect_chain:
+            self.assertIn(reverse('users_public:login'), path)
diff --git a/opentech/apply/funds/tests/views/test_batch_progress.py b/opentech/apply/funds/tests/views/test_batch_progress.py
index b7784f49feb9729fbaacdd2a451184521f8d5005..ac56fbdf75af60dd16e30e9f537a715ed7c7bb02 100644
--- a/opentech/apply/funds/tests/views/test_batch_progress.py
+++ b/opentech/apply/funds/tests/views/test_batch_progress.py
@@ -98,7 +98,7 @@ class StaffTestCase(BaseBatchProgressViewTestCase):
         self.post_page(data=self.data(action, [submission]))
         patched.assert_called_once()
         _, _, kwargs = patched.mock_calls[0]
-        self.assertQuerysetEqual(kwargs['submissions'], ApplicationSubmission.objects.none())
+        self.assertQuerysetEqual(kwargs['sources'], ApplicationSubmission.objects.none())
 
     @mock.patch('opentech.apply.funds.views.messenger')
     def test_messenger_with_submission_in_review(self, patched):
@@ -107,9 +107,9 @@ class StaffTestCase(BaseBatchProgressViewTestCase):
         self.post_page(data=self.data(action, [submission]))
         self.assertEqual(patched.call_count, 2)
         _, _, kwargs = patched.mock_calls[0]
-        self.assertCountEqual(kwargs['submissions'], [submission])
+        self.assertCountEqual(kwargs['sources'], [submission])
         _, _, kwargs = patched.mock_calls[1]
-        self.assertCountEqual(kwargs['submissions'], [submission])
+        self.assertCountEqual(kwargs['sources'], [submission])
 
 
 class ReivewersTestCase(BaseBatchProgressViewTestCase):
diff --git a/opentech/apply/funds/urls.py b/opentech/apply/funds/urls.py
index 386993422d70d095bd1c10dc336fd9c0616fdc74..4f035de13e61112260e7853140ee7eeaf848a5f3 100644
--- a/opentech/apply/funds/urls.py
+++ b/opentech/apply/funds/urls.py
@@ -1,5 +1,7 @@
 from django.urls import include, path
 
+from opentech.apply.projects import urls as projects_urls
+
 from .views import (
     RevisionCompareView,
     RevisionListView,
@@ -37,15 +39,15 @@ app_name = 'funds'
 submission_urls = ([
     path('', SubmissionOverviewView.as_view(), name="overview"),
     path('all/', SubmissionListView.as_view(), name="list"),
-    path(
-        'documents/submission/<int:submission_id>/<uuid:field_id>/<str:file_name>/',
-        SubmissionPrivateMediaView.as_view(), name='serve_private_media'
-    ),
     path('<int:pk>/', include([
         path('', SubmissionDetailView.as_view(), name="detail"),
         path('edit/', SubmissionEditView.as_view(), name="edit"),
         path('sealed/', SubmissionSealedView.as_view(), name="sealed"),
         path('delete/', SubmissionDeleteView.as_view(), name="delete"),
+        path(
+            'documents/<uuid:field_id>/<str:file_name>',
+            SubmissionPrivateMediaView.as_view(), name='serve_private_media'
+        ),
     ])),
     path('<int:submission_pk>/', include([
         path('', include('opentech.apply.review.urls', namespace="reviews")),
@@ -81,5 +83,6 @@ rounds_urls = ([
 urlpatterns = [
     path('submissions/', include(submission_urls)),
     path('rounds/', include(rounds_urls)),
+    path('projects/', include(projects_urls)),
     path('api/', include(api_urls)),
 ]
diff --git a/opentech/apply/funds/views.py b/opentech/apply/funds/views.py
index b2aeb1d20f2e5aacfa5da90570cdb2ca27791902..d118e93365c94c00f457d729b32e1f0f03fe003f 100644
--- a/opentech/apply/funds/views.py
+++ b/opentech/apply/funds/views.py
@@ -1,22 +1,17 @@
-import mimetypes
 from copy import copy
-from wsgiref.util import FileWrapper
 
-from django.conf import settings
 from django.contrib.auth.decorators import login_required, permission_required
 from django.contrib.auth.mixins import UserPassesTestMixin
-from django.contrib.auth.views import redirect_to_login
 from django.contrib import messages
 from django.core.exceptions import PermissionDenied
-from django.core.files.storage import get_storage_class
 from django.db.models import Count, F, Q
-from django.http import HttpResponseRedirect, Http404, StreamingHttpResponse
+from django.http import HttpResponseRedirect, Http404
 from django.shortcuts import get_object_or_404
 from django.urls import reverse_lazy
 from django.utils.decorators import method_decorator
 from django.utils.safestring import mark_safe
 from django.utils.translation import ugettext_lazy as _
-from django.views.generic import DetailView, FormView, ListView, UpdateView, DeleteView, View
+from django.views.generic import CreateView, DetailView, FormView, ListView, UpdateView, DeleteView
 
 from django_filters.views import FilterView
 from django_tables2.views import SingleTableMixin
@@ -31,11 +26,15 @@ from opentech.apply.activity.views import (
 )
 from opentech.apply.activity.messaging import messenger, MESSAGES
 from opentech.apply.determinations.views import BatchDeterminationCreateView, DeterminationCreateOrUpdateView
+from opentech.apply.projects.forms import CreateProjectForm
+from opentech.apply.projects.models import Project
 from opentech.apply.review.views import ReviewContextMixin
 from opentech.apply.users.decorators import staff_required
+from opentech.apply.utils.storage import PrivateMediaView
 from opentech.apply.utils.views import DelegateableListView, DelegateableView, ViewDispatcher
 
 from .differ import compare
+from .files import generate_submission_file_path
 from .forms import (
     BatchUpdateSubmissionLeadForm,
     BatchUpdateReviewersForm,
@@ -54,6 +53,7 @@ from .models import (
     RoundBase,
     LabBase
 )
+from .permissions import is_user_has_access_to_view_submission
 from .tables import (
     AdminSubmissionsTable,
     ReviewerSubmissionsTable,
@@ -64,9 +64,6 @@ from .tables import (
     SummarySubmissionsTable,
 )
 from .workflow import INITIAL_STATE, STAGE_CHANGE_ACTIONS, PHASES_MAPPING, review_statuses
-from .permissions import is_user_has_access_to_view_submission
-
-submission_storage = get_storage_class(getattr(settings, 'PRIVATE_FILE_STORAGE', None))()
 
 
 class BaseAdminSubmissionsTable(SingleTableMixin, FilterView):
@@ -118,7 +115,7 @@ class BatchUpdateLeadView(DelegatedViewMixin, FormView):
             MESSAGES.BATCH_UPDATE_LEAD,
             request=self.request,
             user=self.request.user,
-            submissions=submissions,
+            sources=submissions,
             new_lead=new_lead,
         )
         return super().form_valid(form)
@@ -145,7 +142,7 @@ class BatchUpdateReviewersView(DelegatedViewMixin, FormView):
             MESSAGES.BATCH_REVIEWERS_UPDATED,
             request=self.request,
             user=self.request.user,
-            submissions=submissions,
+            sources=submissions,
             added=reviewers,
         )
         return super().form_valid(form)
@@ -203,7 +200,7 @@ class BatchProgressSubmissionView(DelegatedViewMixin, FormView):
             MESSAGES.BATCH_TRANSITION,
             user=self.request.user,
             request=self.request,
-            submissions=succeeded_submissions,
+            sources=succeeded_submissions,
             related=phase_changes,
         )
 
@@ -216,7 +213,7 @@ class BatchProgressSubmissionView(DelegatedViewMixin, FormView):
                 MESSAGES.BATCH_READY_FOR_REVIEW,
                 user=self.request.user,
                 request=self.request,
-                submissions=succeeded_submissions.filter(status__in=ready_for_review),
+                sources=succeeded_submissions.filter(status__in=ready_for_review),
             )
 
         return super().form_valid(form)
@@ -374,6 +371,29 @@ class ProgressSubmissionView(DelegatedViewMixin, UpdateView):
         return super().form_valid(form)
 
 
+@method_decorator(staff_required, name='dispatch')
+class CreateProjectView(DelegatedViewMixin, CreateView):
+    context_name = 'project_form'
+    form_class = CreateProjectForm
+    model = Project
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.CREATED_PROJECT,
+            request=self.request,
+            user=self.request.user,
+            source=self.object,
+            related=self.object.submission,
+        )
+
+        return response
+
+    def get_success_url(self):
+        return self.object.get_absolute_url()
+
+
 @method_decorator(staff_required, name='dispatch')
 class ScreeningSubmissionView(DelegatedViewMixin, UpdateView):
     model = ApplicationSubmission
@@ -388,7 +408,7 @@ class ScreeningSubmissionView(DelegatedViewMixin, UpdateView):
             MESSAGES.SCREENING,
             request=self.request,
             user=self.request.user,
-            submission=self.object,
+            source=self.object,
             related=str(old.screening_status),
         )
         return response
@@ -408,7 +428,7 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView):
             MESSAGES.UPDATE_LEAD,
             request=self.request,
             user=self.request.user,
-            submission=form.instance,
+            source=form.instance,
             related=old.lead,
         )
         return response
@@ -435,7 +455,7 @@ class UpdateReviewersView(DelegatedViewMixin, UpdateView):
             MESSAGES.REVIEWERS_UPDATED,
             request=self.request,
             user=self.request.user,
-            submission=self.kwargs['submission'],
+            source=self.kwargs['object'],
             added=added,
             removed=removed,
         )
@@ -483,7 +503,7 @@ class UpdatePartnersView(DelegatedViewMixin, UpdateView):
             MESSAGES.PARTNERS_UPDATED,
             request=self.request,
             user=self.request.user,
-            submission=self.kwargs['submission'],
+            source=self.kwargs['object'],
             added=added,
             removed=removed,
         )
@@ -492,7 +512,7 @@ class UpdatePartnersView(DelegatedViewMixin, UpdateView):
             MESSAGES.PARTNERS_UPDATED_PARTNER,
             request=self.request,
             user=self.request.user,
-            submission=self.kwargs['submission'],
+            source=self.kwargs['object'],
             added=added,
             removed=removed,
         )
@@ -517,6 +537,7 @@ class AdminSubmissionDetailView(ReviewContextMixin, ActivityContextMixin, Delega
         UpdateLeadView,
         UpdateReviewersView,
         UpdatePartnersView,
+        CreateProjectView,
         UpdateMetaCategoriesView,
     ]
 
@@ -636,7 +657,7 @@ class SubmissionSealedView(DetailView):
             MESSAGES.OPENED_SEALED,
             request=self.request,
             user=self.request.user,
-            submission=submission,
+            source=submission,
         )
         self.request.session.setdefault('peeked', {})[str(submission.id)] = True
         # Dictionary updates do not trigger session saves. Force update
@@ -714,7 +735,7 @@ class AdminSubmissionEditView(BaseSubmissionEditView):
                     MESSAGES.EDIT,
                     request=self.request,
                     user=self.request.user,
-                    submission=self.object,
+                    source=self.object,
                     related=revision,
                 )
 
@@ -753,14 +774,14 @@ class ApplicantSubmissionEditView(BaseSubmissionEditView):
                 MESSAGES.PROPOSAL_SUBMITTED,
                 request=self.request,
                 user=self.request.user,
-                submission=self.object,
+                source=self.object,
             )
         elif revision:
             messenger(
                 MESSAGES.APPLICANT_EDIT,
                 request=self.request,
                 user=self.request.user,
-                submission=self.object,
+                source=self.object,
                 related=revision,
             )
 
@@ -887,51 +908,26 @@ class SubmissionDeleteView(DeleteView):
             MESSAGES.DELETE_SUBMISSION,
             user=request.user,
             request=request,
-            submission=submission,
+            source=submission,
         )
         response = super().delete(request, *args, **kwargs)
         return response
 
 
-class SubmissionPrivateMediaView(UserPassesTestMixin, View):
+@method_decorator(login_required, name='dispatch')
+class SubmissionPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
 
-    def get(self, *args, **kwargs):
-        submission_id = kwargs['submission_id']
+    def dispatch(self, *args, **kwargs):
+        submission_pk = self.kwargs['pk']
+        self.submission = get_object_or_404(ApplicationSubmission, pk=submission_pk)
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
         field_id = kwargs['field_id']
         file_name = kwargs['file_name']
-        file_name_with_path = f'submission/{submission_id}/{field_id}/{file_name}'
-
-        submission_file = submission_storage.open(file_name_with_path)
-        wrapper = FileWrapper(submission_file)
-        encoding_map = {
-            'bzip2': 'application/x-bzip',
-            'gzip': 'application/gzip',
-            'xz': 'application/x-xz',
-        }
-        content_type, encoding = mimetypes.guess_type(file_name)
-        # Encoding isn't set to prevent browsers from automatically uncompressing files.
-        content_type = encoding_map.get(encoding, content_type)
-        content_type = content_type or 'application/octet-stream'
-        # From Django 2.1, we can use FileResponse instead of StreamingHttpResponse
-        response = StreamingHttpResponse(wrapper, content_type=content_type)
-
-        response['Content-Disposition'] = f'filename={file_name}'
-        response['Content-Length'] = submission_file.size
-
-        return response
+        path_to_file = generate_submission_file_path(self.submission.pk, field_id, file_name)
+        return self.storage.open(path_to_file)
 
     def test_func(self):
-        submission_id = self.kwargs['submission_id']
-        submission = get_object_or_404(ApplicationSubmission, id=submission_id)
-
-        return is_user_has_access_to_view_submission(self.request.user, submission)
-
-    def handle_no_permission(self):
-        # This method can be removed after upgrading Django to 2.1
-        # https://github.com/django/django/commit/9b1125bfc7e2dc747128e6e7e8a2259ff1a7d39f
-        # In older versions, authenticated users who lacked permissions were
-        # redirected to the login page (which resulted in a loop) instead of
-        # receiving an HTTP 403 Forbidden response.
-        if self.raise_exception or self.request.user.is_authenticated:
-            raise PermissionDenied(self.get_permission_denied_message())
-        return redirect_to_login(self.request.get_full_path(), self.get_login_url(), self.get_redirect_field_name())
+        return is_user_has_access_to_view_submission(self.request.user, self.submission)
diff --git a/opentech/apply/funds/workflow.py b/opentech/apply/funds/workflow.py
index fd4a251fc1fdea23ce2bece530b6416b36beca24..bb204567ccd1c8e0ffb32551a13996ef73dafd6f 100644
--- a/opentech/apply/funds/workflow.py
+++ b/opentech/apply/funds/workflow.py
@@ -110,7 +110,7 @@ class Phase:
         return self.display_name
 
     def __repr__(self):
-        return f'<Phase {self.display_name} ({self.public_name})>'
+        return f'<Phase: {self.display_name} ({self.public_name})>'
 
 
 class Stage:
@@ -121,6 +121,9 @@ class Stage:
     def __str__(self):
         return self.name
 
+    def __repr__(self):
+        return f'<Stage: {self.name}>'
+
 
 class Permissions:
     def __init__(self, permissions):
diff --git a/opentech/apply/projects/__init__.py b/opentech/apply/projects/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/apply/projects/admin.py b/opentech/apply/projects/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..711563188a21bdd91c2e29ea110956626a556f9e
--- /dev/null
+++ b/opentech/apply/projects/admin.py
@@ -0,0 +1,17 @@
+from wagtail.contrib.modeladmin.options import ModelAdmin, ModelAdminGroup
+
+from .models import DocumentCategory
+
+
+class DocumentCategoryAdmin(ModelAdmin):
+    model = DocumentCategory
+    menu_icon = 'doc-full'
+    list_display = ('name', 'recommended_minimum',)
+
+
+class ManageAdminGoup(ModelAdminGroup):
+    menu_label = 'Manage'
+    menu_icon = 'folder-open-inverse'
+    items = (
+        DocumentCategoryAdmin,
+    )
diff --git a/opentech/apply/projects/apps.py b/opentech/apply/projects/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..5309c89c193bb7f7d21eb1eaa14f56a9a030792f
--- /dev/null
+++ b/opentech/apply/projects/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ProjectsConfig(AppConfig):
+    name = 'opentech.apply.projects'
+    label = 'application_projects'
diff --git a/opentech/apply/projects/context_processors.py b/opentech/apply/projects/context_processors.py
new file mode 100644
index 0000000000000000000000000000000000000000..b04840afcfa1bc16f139ec39dcac52e4da541387
--- /dev/null
+++ b/opentech/apply/projects/context_processors.py
@@ -0,0 +1,5 @@
+from django.conf import settings
+
+
+def projects_enabled(request):
+    return {'PROJECTS_ENABLED': settings.PROJECTS_ENABLED}
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..13d19826fdf490fba6a6f3b2ca7b60e92158837f
--- /dev/null
+++ b/opentech/apply/projects/forms.py
@@ -0,0 +1,136 @@
+from django import forms
+from django.db.models import Q
+
+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, PacketFile, Project
+
+
+class CreateProjectForm(forms.Form):
+    submission = forms.ModelChoiceField(
+        queryset=ApplicationSubmission.objects.filter(project__isnull=True),
+        widget=forms.HiddenInput(),
+    )
+
+    def __init__(self, instance=None, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if instance:
+            self.fields['submission'].initial = instance.id
+
+    def save(self, *args, **kwargs):
+        submission = self.cleaned_data['submission']
+        return Project.create_from_submission(submission)
+
+
+class CreateApprovalForm(forms.ModelForm):
+    class Meta:
+        model = Approval
+        fields = ['by']
+        widgets = {'by': forms.HiddenInput()}
+
+    def __init__(self, user=None, *args, **kwargs):
+        initial = kwargs.pop('initial', {})
+        initial.update(by=user)
+        super().__init__(*args, initial=initial, **kwargs)
+
+
+class ProjectEditForm(forms.ModelForm):
+    contact_address = AddressField()
+
+    class Meta:
+        fields = [
+            'title',
+            'contact_legal_name',
+            'contact_email',
+            'contact_address',
+            'contact_phone',
+            'value',
+            'proposed_start',
+            'proposed_end',
+        ]
+        model = Project
+        widgets = {
+            'title': forms.TextInput,
+            'contact_legal_name': forms.TextInput,
+            'contact_email': forms.TextInput,
+            'contact_phone': forms.TextInput,
+            'proposed_end': forms.DateInput,
+            'proposed_start': forms.DateInput,
+        }
+
+
+class ProjectApprovalForm(ProjectEditForm):
+    def save(self, *args, **kwargs):
+        self.instance.user_has_updated_details = True
+        return super().save(*args, **kwargs)
+
+
+class RejectionForm(forms.Form):
+    comment = forms.CharField(widget=forms.Textarea)
+
+    def __init__(self, instance=None, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class RemoveDocumentForm(forms.ModelForm):
+    id = forms.IntegerField(widget=forms.HiddenInput())
+
+    class Meta:
+        fields = ['id']
+        model = PacketFile
+
+    def __init__(self, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+
+class SetPendingForm(forms.ModelForm):
+    class Meta:
+        fields = ['id']
+        model = Project
+        widgets = {'id': forms.HiddenInput()}
+
+    def __init__(self, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def clean(self):
+        if self.instance.status != COMMITTED:
+            raise forms.ValidationError('A Project can only be sent for Approval when Committed.')
+
+        if self.instance.is_locked:
+            raise forms.ValidationError('A Project can only be sent for Approval once')
+
+        super().clean()
+
+    def save(self, *args, **kwargs):
+        self.instance.is_locked = True
+        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']
+        model = Project
+
+    def __init__(self, user=None, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        lead_field = self.fields['lead']
+        lead_field.label = f'Update lead from {self.instance.lead} to'
+
+        qwargs = Q(groups__name=STAFF_GROUP_NAME) | Q(is_superuser=True)
+        lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id)
+                                                  .filter(qwargs)
+                                                  .distinct())
diff --git a/opentech/apply/projects/migrations/0001_initial.py b/opentech/apply/projects/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..76e3451ce5468d5f5e00d4b15869b977e286677a
--- /dev/null
+++ b/opentech/apply/projects/migrations/0001_initial.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.0.13 on 2019-07-29 07:49
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('funds', '0064_group_toggle_end_block'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Project',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.TextField()),
+                ('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='funds.ApplicationSubmission')),
+            ],
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py b/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8856252fead9be837a0b4f661313034b77ddfd1
--- /dev/null
+++ b/opentech/apply/projects/migrations/0002_add_submission_fields_to_project.py
@@ -0,0 +1,33 @@
+# Generated by Django 2.0.13 on 2019-07-30 10:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='contact_address',
+            field=models.TextField(default=''),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='contact_email',
+            field=models.TextField(default=''),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='contact_legal_name',
+            field=models.TextField(default=''),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='value',
+            field=models.DecimalField(decimal_places=2, default=0, max_digits=10),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0003_add_project_lead.py b/opentech/apply/projects/migrations/0003_add_project_lead.py
new file mode 100644
index 0000000000000000000000000000000000000000..2699ce4b680cf93c13f080948e29f14370cc4976
--- /dev/null
+++ b/opentech/apply/projects/migrations/0003_add_project_lead.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.0.13 on 2019-07-31 13:25
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0002_add_submission_fields_to_project'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='lead',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py b/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py
new file mode 100644
index 0000000000000000000000000000000000000000..2e735813cb68516d2da0ae8e9102ef2c86b3510a
--- /dev/null
+++ b/opentech/apply/projects/migrations/0004_project_rename_name_to_title.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-07-11 03:20
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0003_add_project_lead'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='project',
+            old_name='name',
+            new_name='title',
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0005_add_user_to_project.py b/opentech/apply/projects/migrations/0005_add_user_to_project.py
new file mode 100644
index 0000000000000000000000000000000000000000..b403e5f14d4de9a0e5e458146b4b7e4443277514
--- /dev/null
+++ b/opentech/apply/projects/migrations/0005_add_user_to_project.py
@@ -0,0 +1,26 @@
+# Generated by Django 2.0.13 on 2019-07-11 03:39
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0004_project_rename_name_to_title'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='user',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_projects', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='project',
+            name='lead',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='lead_projects', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0006_add_project_paf_fields.py b/opentech/apply/projects/migrations/0006_add_project_paf_fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..fd61f3cb20dd0684d853f464fa53cef87f931ea1
--- /dev/null
+++ b/opentech/apply/projects/migrations/0006_add_project_paf_fields.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.0.13 on 2019-08-02 14:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0005_add_user_to_project'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='contact_phone',
+            field=models.TextField(default='', verbose_name='Phone'),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='proposed_end',
+            field=models.DateTimeField(null=True, verbose_name='Proposed Start Date'),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='proposed_start',
+            field=models.DateTimeField(null=True, verbose_name='Proposed Start Date'),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='user_has_updated_details',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='project',
+            name='contact_address',
+            field=models.TextField(default='', verbose_name='Address'),
+        ),
+        migrations.AlterField(
+            model_name='project',
+            name='contact_email',
+            field=models.TextField(default='', verbose_name='Email'),
+        ),
+        migrations.AlterField(
+            model_name='project',
+            name='contact_legal_name',
+            field=models.TextField(default='', verbose_name='Person or Organisation name'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py b/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py
new file mode 100644
index 0000000000000000000000000000000000000000..3dae6c5a1ac2fcfac3331d7d4f1bfc07d66a0f13
--- /dev/null
+++ b/opentech/apply/projects/migrations/0007_fix_proposed_end_date_verbose_name.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.13 on 2019-08-07 08:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0006_add_project_paf_fields'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='project',
+            name='proposed_end',
+            field=models.DateTimeField(null=True, verbose_name='Proposed End Date'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0008_add_value_validator.py b/opentech/apply/projects/migrations/0008_add_value_validator.py
new file mode 100644
index 0000000000000000000000000000000000000000..27c2506895ffb54a4d911663b73339f3d56dbffc
--- /dev/null
+++ b/opentech/apply/projects/migrations/0008_add_value_validator.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.13 on 2019-08-07 08:38
+
+from decimal import Decimal
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0007_fix_proposed_end_date_verbose_name'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='project',
+            name='value',
+            field=models.DecimalField(decimal_places=2, default=0, max_digits=10, validators=[django.core.validators.MinValueValidator(Decimal('0.01'))]),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0009_add_approval.py b/opentech/apply/projects/migrations/0009_add_approval.py
new file mode 100644
index 0000000000000000000000000000000000000000..92b0c41572e2d1549f53767d3da3dcff0b4424ae
--- /dev/null
+++ b/opentech/apply/projects/migrations/0009_add_approval.py
@@ -0,0 +1,50 @@
+# Generated by Django 2.0.13 on 2019-08-06 09:30
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('application_projects', '0009_documentcategory'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Approval',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='created_at',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='is_locked',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='project',
+            name='status',
+            field=models.TextField(choices=[('committed', 'Committed'), ('contracting', 'Contracting'), ('in_progress', 'In Progress'), ('closing', 'Closing'), ('complete', 'Complete')], default='committed'),
+        ),
+        migrations.AddField(
+            model_name='approval',
+            name='project',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='application_projects.Project'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='approval',
+            unique_together={('project', 'by')},
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0009_documentcategory.py b/opentech/apply/projects/migrations/0009_documentcategory.py
new file mode 100644
index 0000000000000000000000000000000000000000..6925bcfa68c739c81951cf8a1bc8a21111caeb44
--- /dev/null
+++ b/opentech/apply/projects/migrations/0009_documentcategory.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.0.13 on 2019-08-06 22:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0008_add_value_validator'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DocumentCategory',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=254)),
+                ('recommended_minimum', models.PositiveIntegerField()),
+            ],
+            options={
+                'ordering': ('name',),
+                'verbose_name_plural': 'Document Categories',
+            },
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py b/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py
new file mode 100644
index 0000000000000000000000000000000000000000..8bae011649d8504a7e77c3cb51548dc151b02b13
--- /dev/null
+++ b/opentech/apply/projects/migrations/0010_add_related_names_to_approval_fks.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.0.13 on 2019-08-07 11:03
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0009_add_approval'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='approval',
+            name='by',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to=settings.AUTH_USER_MODEL),
+        ),
+        migrations.AlterField(
+            model_name='approval',
+            name='project',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='approvals', to='application_projects.Project'),
+        ),
+    ]
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/migrations/0012_adjust_storage_class.py b/opentech/apply/projects/migrations/0012_adjust_storage_class.py
new file mode 100644
index 0000000000000000000000000000000000000000..7834f8d2758b480be70a754dab1375eea9b50bb1
--- /dev/null
+++ b/opentech/apply/projects/migrations/0012_adjust_storage_class.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.0.13 on 2019-08-10 09:54
+
+import django.core.files.storage
+from django.db import migrations, models
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0011_add_packet_file'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='packetfile',
+            name='document',
+            field=models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.document_path),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/__init__.py b/opentech/apply/projects/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..4b3f1b98f6011ec827ed8e4632f7495a43134df7
--- /dev/null
+++ b/opentech/apply/projects/models.py
@@ -0,0 +1,218 @@
+import collections
+import decimal
+import json
+import logging
+
+from addressfield.fields import ADDRESS_FIELDS_ORDER
+from django.conf import settings
+from django.contrib.contenttypes.fields import GenericRelation
+from django.core.exceptions import ValidationError
+from django.core.validators import MinValueValidator
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import ugettext as _
+
+from opentech.apply.utils.storage import PrivateStorage
+
+
+logger = logging.getLogger(__name__)
+
+
+class Approval(models.Model):
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals")
+    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals")
+
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    class Meta:
+        unique_together = ['project', 'by']
+
+    def __str__(self):
+        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, storage=PrivateStorage())
+
+    def __str__(self):
+        return f'Project file: {self.title}'
+
+    def get_remove_form(self):
+        """
+        Get an instantiated RemoveDocumentForm with this class as `instance`.
+
+        This allows us to build instances of the RemoveDocumentForm for each
+        instance of PacketFile in the supporting documents template.  The
+        standard Delegated View flow makes it difficult to create these forms
+        in the view or template.
+        """
+        from .forms import RemoveDocumentForm
+        return RemoveDocumentForm(instance=self)
+
+
+COMMITTED = 'committed'
+CONTRACTING = 'contracting'
+PROJECT_STATUS_CHOICES = [
+    (COMMITTED, 'Committed'),
+    (CONTRACTING, 'Contracting'),
+    ('in_progress', 'In Progress'),
+    ('closing', 'Closing'),
+    ('complete', 'Complete'),
+]
+
+
+class Project(models.Model):
+    lead = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name='lead_projects')
+    submission = models.OneToOneField("funds.ApplicationSubmission", on_delete=models.CASCADE)
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='owned_projects')
+
+    title = models.TextField()
+
+    contact_legal_name = models.TextField(_('Person or Organisation name'), default='')
+    contact_email = models.TextField(_('Email'), default='')
+    contact_address = models.TextField(_('Address'), default='')
+    contact_phone = models.TextField(_('Phone'), default='')
+    value = models.DecimalField(
+        default=0,
+        max_digits=10,
+        decimal_places=2,
+        validators=[MinValueValidator(decimal.Decimal('0.01'))],
+    )
+    proposed_start = models.DateTimeField(_('Proposed Start Date'), null=True)
+    proposed_end = models.DateTimeField(_('Proposed End Date'), null=True)
+
+    status = models.TextField(choices=PROJECT_STATUS_CHOICES, default=COMMITTED)
+
+    # tracks read/write state of the Project
+    is_locked = models.BooleanField(default=False)
+
+    # tracks updates to the Projects fields via the Project Application Form.
+    user_has_updated_details = models.BooleanField(default=False)
+
+    activities = GenericRelation(
+        'activity.Activity',
+        content_type_field='source_content_type',
+        object_id_field='source_object_id',
+        related_query_name='project',
+    )
+    created_at = models.DateTimeField(auto_now_add=True)
+
+    def __str__(self):
+        return self.title
+
+    def get_address_display(self):
+        address = json.loads(self.contact_address)
+        return ', '.join(
+            address.get(field)
+            for field in ADDRESS_FIELDS_ORDER
+            if address.get(field)
+        )
+
+    @classmethod
+    def create_from_submission(cls, submission):
+        """
+        Create a Project from the given submission.
+
+        Returns a new Project or the given ApplicationSubmissions existing
+        Project.
+        """
+        if not settings.PROJECTS_ENABLED:
+            logging.error(f'Tried to create a Project for Submission ID={submission.id} while projects are disabled')
+            return None
+
+        # OneToOne relations on the targetted model cannot be accessed without
+        # an exception when the relation doesn't exist (is None).  Since we
+        # want to fail fast here, we can use hasattr instead.
+        if hasattr(submission, 'project'):
+            return submission.project
+
+        return Project.objects.create(
+            submission=submission,
+            title=submission.title,
+            user=submission.user,
+            contact_email=submission.user.email,
+            contact_legal_name=submission.user.full_name,
+            contact_address=submission.form_data.get('address', ''),
+            value=submission.form_data.get('value', 0),
+        )
+
+    def clean(self):
+        if self.proposed_start is None:
+            return
+
+        if self.proposed_end is None:
+            return
+
+        if self.proposed_start > self.proposed_end:
+            raise ValidationError(_('Proposed End Date must be after Proposed Start Date'))
+
+    def editable_by(self, user):
+        if self.editable:
+            return True
+
+        # Approver can edit it when they are approving
+        return user.is_approver and self.can_make_approval
+
+    @property
+    def editable(self):
+        # Someone must lead the project to make changes
+        return self.lead and not self.is_locked
+
+    def get_absolute_url(self):
+        if settings.PROJECTS_ENABLED:
+            return reverse('apply:projects:detail', args=[self.id])
+        return '#'
+
+    @property
+    def can_make_approval(self):
+        return self.is_locked and self.status == COMMITTED
+
+    @property
+    def can_send_for_approval(self):
+        """
+        Wrapper to expose the pending approval state
+
+        We don't want to expose a "Sent for Approval" state to the end User so
+        we infer it from the current status being "Comitted" and the Project
+        being locked.
+        """
+        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)
+    recommended_minimum = models.PositiveIntegerField()
+
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        ordering = ('name',)
+        verbose_name_plural = 'Document Categories'
diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
new file mode 100644
index 0000000000000000000000000000000000000000..8e3e1acc21c727fc912a6082bbc4aa8143343eba
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
@@ -0,0 +1,115 @@
+{% load approval_tools %}
+{% user_can_edit_project object request.user as editable %}
+
+<div class="docs-block wrapper--outer-space-large">
+    <div class="docs-block__header">
+        <h4 class="docs-block__heading">Supporting documents</h4>
+    </div>
+    <ul class="docs-block__inner">
+
+        <li class="docs-block__row">
+            <div class="docs-block__row-inner">
+                <svg class="icon docs-block__icon is-complete"><use xlink:href="#tick"></use></svg>
+                <p class="docs-block__title">Proposal</p>
+            </div>
+            <div class="docs-block__row-inner">
+                <a class="docs-block__link" href="#">View</a>
+                <a class="docs-block__link" href="#">Download</a>
+            </div>
+        </li>
+
+        <li class="docs-block__row">
+            <div class="docs-block__row-inner">
+                <svg class="icon docs-block__icon{% if object.user_has_updated_details %} is-complete{% endif %}">
+                    <use xlink:href="#tick"></use>
+                </svg>
+                <p class="docs-block__title">Approval Form</p>
+            </div>
+            <div class="docs-block__row-inner">
+                {% if editable %}
+                    <a class="docs-block__link" href="{% url 'apply:projects:edit' pk=object.pk %}">
+                        {% if object.user_has_updated_details %}
+                        Edit
+                        {% else %}
+                        Create
+                        {% endif %}
+                    </a>
+                {% endif %}
+                {% if object.user_has_updated_details %}
+                    <a class="docs-block__link" href="#">
+                        View
+                    </a>
+                {% endif %}
+            </div>
+        </li>
+
+        <li class="docs-block__row">
+            <div class="docs-block__row-inner">
+                <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg>
+                <p class="docs-block__title">Supporting documents</p>
+            </div>
+            {% if editable %}
+                <div class="docs-block__row-inner">
+                    <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a>
+                </div>
+            {% endif %}
+            {% if remaining_document_categories %}
+            <div class="docs-block__info-text">
+                <p>
+                    Every project should include the following documents:
+                </p>
+                <ul>
+                    {% for missing in remaining_document_categories %}
+                        <li>{{ missing.category.name }} ({{ missing.difference }})</li>
+                    {% endfor %}
+                </ul>
+            </div>
+            {% endif %}
+
+            {% 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="{% url 'apply:projects:document' pk=object.pk file_pk=document.pk %}">Download</a>
+                        <form method="POST" id="{{ remove_document_form.name }}">
+                            {% csrf_token %}
+                            {{ document.get_remove_form }}
+                            <input
+                                class="button button--primary button--top-space"
+                                id="{{ remove_document_form.name }}-submit"
+                                name="{{ form_prefix }}{{ remove_document_form.name }}"
+                                type="submit"
+                                form="{{ remove_document_form.name }}"
+                                value="Remove" />
+                        </form>
+                            </input>
+                    </div>
+                </li>
+                {% endfor %}
+            </ul>
+            {% endif %}
+
+        </li>
+    </ul>
+    <div class="docs-block__buttons">
+        {% if object.can_send_for_approval %}
+            <a data-fancybox
+            data-src="#send-for-approval"
+            class="button button--primary"
+            href="#">
+                Submit for Approval
+            </a>
+        {% endif %}
+        <!-- <button class="button button--primary" href="#">Ready for contracting</button> -->
+    </div>
+</div>
+
+<div class="modal" id="upload-supporting-doc">
+    <h4 class="modal__header-bar">Upload a new document</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%}
+</div>
diff --git a/opentech/apply/projects/templates/application_projects/project_admin_detail.html b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..239d18fb9c923900e6e6dac011a4193b54be5821
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_admin_detail.html
@@ -0,0 +1,120 @@
+{% extends "application_projects/project_detail.html" %}
+
+{% load approval_tools %}
+{% load static %}
+
+{% block admin_sidebar %}
+<div class="modal" id="send-for-approval">
+    <h4 class="modal__header-bar">Request Approval</h4>
+
+    {% if remaining_document_categories %}
+        <h5>Are you sure you're ready to submit?</h5>
+
+        <p>This project is missing the following documents:</p>
+
+        <ul>
+            {% for missing in remaining_document_categories %}
+            <li><strong>{{ missing.category.name }} ({{ missing.difference }})</strong></li>
+            {% endfor %}
+        </ul>
+        {% include 'funds/includes/delegated_form_base.html' with form=request_approval_form value='Submit anyway' cancel=True invert=True %}
+    {% else %}
+        {% include 'funds/includes/delegated_form_base.html' with form=request_approval_form value='Request' %}
+    {% endif %}
+</div>
+
+<div class="modal" id="assign-lead">
+    <h4 class="modal__header-bar">Assign Lead</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=lead_form value='Update'%}
+</div>
+
+<div class="modal" id="approve">
+    <h4 class="modal__header-bar">Add Approval</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=add_approval_form value='Approve'%}
+</div>
+
+<div class="modal" id="request-project-changes">
+    <h4 class="modal__header-bar">Request Changes</h4>
+    {% include 'funds/includes/delegated_form_base.html' with form=rejection_form value='Request Changes'%}
+</div>
+
+{% if mobile %}
+    <a class="js-actions-toggle button button--white button--full-width button--actions">Actions to take</a>
+{% endif %}
+
+<div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions {% if mobile %}sidebar__inner--mobile{% endif %}">
+
+    <h5>Actions to take</h5>
+
+    {% if object.can_send_for_approval %}
+    <a data-fancybox
+       data-src="#send-for-approval"
+       class="button button--bottom-space button--primary button--full-width"
+       href="#">
+        Submit for Approval
+    </a>
+    {% endif %}
+
+    {% if object.can_make_approval %}
+        {% user_can_approve_project object request.user as user_can_approve %}
+        <a data-fancybox
+            data-src="#approve"
+            class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
+            href="#">
+            Approve
+        </a>
+
+        <a data-fancybox
+            data-src="#request-project-changes"
+           class="button button--bottom-space button--primary button--full-width {% if user_can_approve %}is-not-disabled{% else %}is-disabled{% endif %}"
+            href="#">
+            Request changes
+        </a>
+    {% endif %}
+
+    <!-- <a data-fancybox -->
+    <!-- data-src="#ready-for-contracting" -->
+    <!-- class="button button--primary button--full-width" -->
+    <!-- href="#"> -->
+    <!-- Ready for contracting -->
+    <!-- </a> -->
+
+
+    <p class="sidebar__separator">Assign</p>
+
+    <a data-fancybox
+       data-src="#assign-lead"
+       class="button button--bottom-space button--white button--full-width"
+       href="#">
+        Lead
+    </a>
+
+    <!-- <a data-fancybox -->
+    <!-- data-src="#update-meta-categories" -->
+    <!-- class="button button--bottom-space button--white button--full-width" -->
+    <!-- href="#"> -->
+    <!-- Meta Categories -->
+    <!-- </a> -->
+
+</div>
+
+{% if approvals %}
+<div class="sidebar__inner">
+    <h5>Approved By:</h5>
+
+    {% for approval in approvals %}
+    <p>{{ approval.by }} - {{ approval.created_at|date:"Y-m-d" }}</p>
+    {% endfor %}
+</div>
+{% endif %}
+
+{% endblock %}
+
+{% block extra_css %}
+    <link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block extra_js %}
+    {{ block.super }}
+    <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..2ca4b45978990b5f19dc4d1b5537fd11141a2aa4
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html
@@ -0,0 +1,18 @@
+{% extends "application_projects/project_detail.html" %}
+
+{% block notifications %}
+{% if not object.editable %}
+<div class="wrapper wrapper--sidebar">
+    <div class="wrapper--sidebar--inner wrapper--error">
+        <div>
+            <p>Your project is not editable at this point.</p>
+            {% if not object.lead %}
+                <p>We are awaiting a lead to be assigned.</p>
+            {% else %}
+                <p>It is currently under review by a staff member.</p>
+            {% endif %}
+        </div>
+    </div>
+</div>
+{% endif %}
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..73ef24b09b9a8ede66a101f35258632a2ba07b28
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -0,0 +1,157 @@
+{% extends "base-apply.html" %}
+
+{% load static  %}
+{% load wagtailcore_tags %}
+
+{% block title %}{{ object.title }}{% endblock %}
+
+{% block body_class %}{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <h1 class="beta heading heading--no-margin heading--bold">{{ object.title }}</h1>
+        <h5 class="heading heading--meta">
+
+            <span>
+                {% if public_page %}
+                <a class="link--transparent link--underlined" href="{% pageurl public_page %}" >
+                    {{ object.submission.page }}
+                </a>
+                {% else %}
+                {{ object.submission.page }}
+                {% endif %}
+            </span>
+
+            {% if request.user.is_apply_staff and object.submission.round %}
+            <span>
+                <a class="link--transparent link--underlined" href="{% url 'apply:rounds:detail' pk=object.submission.round.pk %}">{{ object.submission.round }}</a>
+            </span>
+            {% else %}
+                <span>{{ object.submission.round }}</span>
+            {% endif %}
+
+            <span>Lead: {{ object.lead }}</span>
+
+        </h5>
+        {# {% status_bar object.workflow object.phase request.user same_stage=True%} #}
+
+        <div class="tabs js-tabs">
+            <div class="tabs__container">
+                <a class="tab__item" href="#details" data-tab="tab-1">
+                    Details
+                </a>
+
+                <a class="tab__item" href="#communications" data-tab="tab-2">
+                    Communications
+                </a>
+
+                <a class="tab__item" href="#activity-feed" data-tab="tab-3">
+                    Activity Feed
+                </a>
+            </div>
+        </div>
+    </div>
+</div>
+
+<div class="wrapper wrapper--large wrapper--tabs js-tabs-content">
+    <div class="tabs__content" id="tab-1">
+        {% block notifications %}
+        {% endblock %}
+        <div class="wrapper wrapper--sidebar">
+            <article class="wrapper--sidebar--inner">
+                <h3>Project Information</h3>
+                <div class="grid grid--proposal-info">
+                    <div>
+                        <h5>Proposed start date</h5>
+                        <p>{{ object.proposed_start|date:"j F Y"|default:"-" }}</p>
+                    </div>
+
+                    <div>
+                        <h5>Project Proposed end date</h5>
+                        <p>{{ object.proposed_end|date:"j F Y"|default:"-" }}</p>
+                    </div>
+
+                    <div>
+                        <h5>Legal name</h5>
+                        <p>{{ object.contact_legal_name|default:"-" }}</p>
+                    </div>
+
+                    <div>
+                        <h5>Email</h5>
+                        {% if object.contact_email %}
+                        <a href="mailto:{{ object.contact_email }}">{{ object.contact_email }}</a>
+                        {% else %}
+                        -
+                        {% endif %}
+                    </div>
+                </div>
+
+                <a class="link--reveal-proposal js-toggle-propsoal-info" href="#">Show more</a>
+
+                <div class="rich-text--hidden js-rich-text-hidden">
+                    <div>
+                        <h5>Address</h5>
+                        <p>{{ object.get_address_display|default:"-"}}</p>
+                    </div>
+
+                    <div>
+                        <h5>Phone</h5>
+                        <p>{{ object.phone|default:"-" }}</p>
+                    </div>
+
+                    <div>
+                        <h5>Value</h5>
+                        <p>${{ object.value|default:"-" }}</p>
+                    </div>
+                </div>
+
+                {# <div class="wrapper wrapper--outer-space-large"> #}
+                {#     {% include "funds/includes/funding_block.html" %} #}
+                {#     {% include "funds/includes/payment_requests.html" %} #}
+                {#     {% include "funds/includes/invoice_block.html" %} #}
+                {# </div> #}
+
+                {% include "application_projects/includes/supporting_documents.html" %}
+            </article>
+
+            <aside class="sidebar">
+                {% if request.user.is_apply_staff %}
+                {% block admin_sidebar %}{% endblock %}
+                {% endif %}
+
+                <div class="sidebar__inner">
+                    <h5>Meta Categories</h5>
+
+                    <p>Meta Category</p>
+                    <p>Meta Category</p>
+                    <p>Meta Category</p>
+                </div>
+
+            </aside>
+        </div>
+    </div>
+
+    {# Tab 2 #}
+    <div class="tabs__content" id="tab-2">
+        <div class="feed">
+            {% include "activity/include/comment_form.html" %}
+            {% include "activity/include/comment_list.html" with editable=True %}
+        </div>
+    </div>
+
+    {# Tab 3 #}
+    <div class="tabs__content" id="tab-3">
+        <div class="feed">
+            {% include "activity/include/action_list.html" %}
+        </div>
+    </div>
+</div>
+{% endblock content %}
+
+{% block extra_js %}
+    <script src="{% static 'js/apply/tabs.js' %}"></script>
+    <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script>
+    <script src="{% static 'js/apply/file-uploads.js' %}"></script>
+    <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_form.html b/opentech/apply/projects/templates/application_projects/project_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..6834a47d5e56a0cc822ea79be4d4ea0eea28ee70
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/project_form.html
@@ -0,0 +1,40 @@
+{% extends "base-apply.html" %}
+
+{% load static %}
+
+{% block title %}Editing: {{ object.title }}{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <h2 class="heading heading--no-margin">Editing: {{ object.title}}</h2>
+    </div>
+</div>
+
+{% include "forms/includes/form_errors.html" with form=form %}
+
+<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar">
+    <div class="wrapper--sidebar--inner">
+        <form class="form" method="post">
+            {% csrf_token %}
+
+            {% for field in form %}
+                {% if field.field %}
+                    {% include "forms/includes/field.html" %}
+                {% else %}
+                    {{ field }}
+                {% endif %}
+            {% endfor %}
+
+            <a class="button button--submit button--top-space button--white" href="{{ object.get_absolute_url }}">
+                Cancel
+            </a>
+
+            <button class="button button--submit button--top-space button--primary" type="submit">
+                Save
+            </button>
+
+        </form>
+    </div>
+</div>
+{% endblock %}
diff --git a/opentech/apply/projects/templatetags/__init__.py b/opentech/apply/projects/templatetags/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..2c51d1a59462bedbe371bdc18f82c75a6598196a
--- /dev/null
+++ b/opentech/apply/projects/templatetags/approval_tools.py
@@ -0,0 +1,18 @@
+from django import template
+
+register = template.Library()
+
+
+def user_has_approved(project, user):
+    """Has the given User already approved the given Project"""
+    return project.approvals.filter(by=user).exists()
+
+
+@register.simple_tag
+def user_can_approve_project(project, user):
+    return user.is_approver and not user_has_approved(project, user)
+
+
+@register.simple_tag
+def user_can_edit_project(project, user):
+    return project.editable_by(user)
diff --git a/opentech/apply/projects/tests/__init__.py b/opentech/apply/projects/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
new file mode 100644
index 0000000000000000000000000000000000000000..b91c0aadf1e1c82f41884838451e7852c18018be
--- /dev/null
+++ b/opentech/apply/projects/tests/factories.py
@@ -0,0 +1,78 @@
+import decimal
+import json
+
+import factory
+from django.utils import timezone
+
+from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
+from opentech.apply.projects.models import (
+    DocumentCategory,
+    PacketFile,
+    Project,
+)
+from opentech.apply.users.tests.factories import StaffFactory, UserFactory
+
+
+ADDRESS = {
+    'country': 'GB',
+    'thoroughfare': factory.Faker('street_name').generate({}),
+    'premise': factory.Faker('building_number').generate({}),
+    'locality': {
+        'localityname': factory.Faker('city').generate({}),
+        'administrativearea': factory.Faker('city').generate({}),
+        'postal_code': 'SW1 4AQ',
+    }
+}
+
+
+def address_to_form_data():
+    """
+    Generate a AddressField compatible dictionary from the address data
+    """
+    return {
+        'contact_address_0': ADDRESS['country'],
+        'contact_address_1': ADDRESS['thoroughfare'],
+        'contact_address_2': ADDRESS['premise'],
+        'contact_address_3_0': ADDRESS['locality']['localityname'],
+        'contact_address_3_1': ADDRESS['locality']['administrativearea'],
+        'contact_address_3_2': ADDRESS['locality']['postal_code'],
+    }
+
+
+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)
+
+    title = factory.Sequence('name {}'.format)
+    lead = factory.SubFactory(StaffFactory)
+    contact_legal_name = 'test'
+    contact_email = 'test@example.com'
+    contact_address = json.dumps(ADDRESS)
+    contact_phone = '555 1234'
+    value = decimal.Decimal('100')
+    proposed_start = factory.LazyFunction(timezone.now)
+    proposed_end = factory.LazyFunction(timezone.now)
+
+    is_locked = False
+
+    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_forms.py b/opentech/apply/projects/tests/test_forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..5c0d157d5b81315841a9134cb98551d30d50d1b7
--- /dev/null
+++ b/opentech/apply/projects/tests/test_forms.py
@@ -0,0 +1,26 @@
+from django.test import TestCase
+
+from ..forms import ProjectApprovalForm
+from .factories import ProjectFactory, address_to_form_data
+
+
+class TestProjectApprovalForm(TestCase):
+    def test_updating_fields_sets_changed_flag(self):
+        project = ProjectFactory()
+
+        self.assertFalse(project.user_has_updated_details)
+
+        data = {
+            'title': f'{project.title} test',
+            'contact_legal_name': project.contact_legal_name,
+            'contact_email': project.contact_email,
+            'contact_phone': project.contact_phone,
+            'value': project.value,
+            'proposed_start': project.proposed_start,
+            'proposed_end': project.proposed_end,
+        }
+        data.update(address_to_form_data())
+        form = ProjectApprovalForm(instance=project, data=data)
+        form.save()
+
+        self.assertTrue(project.user_has_updated_details)
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..ded83fe644999416caf85afe0ef196c180b707fd
--- /dev/null
+++ b/opentech/apply/projects/tests/test_models.py
@@ -0,0 +1,61 @@
+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):
+    def test_create_from_submission(self):
+        submission = ApplicationSubmissionFactory()
+
+        project = Project.create_from_submission(submission)
+
+        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_settings.py b/opentech/apply/projects/tests/test_settings.py
new file mode 100644
index 0000000000000000000000000000000000000000..a627795b18231c015c2822138764dcb765fc82ad
--- /dev/null
+++ b/opentech/apply/projects/tests/test_settings.py
@@ -0,0 +1,15 @@
+from django.test import TestCase, override_settings
+
+from opentech.apply.users.tests.factories import StaffFactory
+
+
+class TestProjectFeatureFlag(TestCase):
+    @override_settings(PROJECTS_ENABLED=False)
+    def test_urls_404_when_turned_off(self):
+        self.client.force_login(StaffFactory())
+
+        response = self.client.get('/apply/projects/', follow=True)
+        self.assertEqual(response.status_code, 404)
+
+        response = self.client.get('/apply/projects/1/', follow=True)
+        self.assertEqual(response.status_code, 404)
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..511bcdfb0aaa197ac221becc1aaff112b615a1b7
--- /dev/null
+++ b/opentech/apply/projects/tests/test_views.py
@@ -0,0 +1,340 @@
+from io import BytesIO
+
+from django.contrib.auth.models import AnonymousUser
+from django.urls import reverse
+
+from opentech.apply.funds.tests.factories import LabSubmissionFactory
+from opentech.apply.users.tests.factories import (
+    ApplicantFactory,
+    ApproverFactory,
+    ReviewerFactory,
+    StaffFactory,
+    SuperUserFactory,
+    UserFactory,
+)
+from opentech.apply.utils.testing.tests import BaseViewTestCase
+
+from ..forms import SetPendingForm
+from .factories import (
+    DocumentCategoryFactory,
+    PacketFileFactory,
+    ProjectFactory,
+)
+
+
+class TestCreateApprovalView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_creating_an_approval_happy_path(self):
+        project = ProjectFactory()
+        self.assertEqual(project.approvals.count(), 0)
+
+        response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+        approval = project.approvals.first()
+
+        self.assertEqual(project.approvals.count(), 1)
+        self.assertFalse(project.is_locked)
+        self.assertEqual(project.status, 'contracting')
+
+        self.assertEqual(approval.project_id, project.pk)
+
+
+class BaseProjectDetailTestCase(BaseViewTestCase):
+    url_name = 'funds:projects:{}'
+    base_view_name = 'detail'
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+
+class TestStaffProjectDetailView(BaseProjectDetailTestCase):
+    user_factory = StaffFactory
+
+    def test_has_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 200)
+
+    def test_lab_project_renders(self):
+        project = ProjectFactory(submission=LabSubmissionFactory())
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 200)
+
+
+class TestUserProjectDetailView(BaseProjectDetailTestCase):
+    user_factory = UserFactory
+
+    def test_doesnt_have_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 403)
+
+    def test_owner_has_access(self):
+        project = ProjectFactory(user=self.user)
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 200)
+
+
+class TestSuperUserProjectDetailView(BaseProjectDetailTestCase):
+    user_factory = SuperUserFactory
+
+    def test_has_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 200)
+
+
+class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase):
+    user_factory = ReviewerFactory
+
+    def test_doesnt_have_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+        self.assertEqual(response.status_code, 403)
+
+
+class TestRemoveDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_remove_document(self):
+        project = ProjectFactory()
+        document = PacketFileFactory()
+
+        response = self.post_page(project, {
+            'form-submitted-remove_document_form': '',
+            'id': document.id,
+        })
+        project.refresh_from_db()
+
+        self.assertEqual(response.status_code, 200)
+        self.assertNotIn(document.pk, project.packet_files.values_list('pk', flat=True))
+
+    def test_remove_non_existent_document(self):
+        response = self.post_page(ProjectFactory(), {
+            'form-submitted-remove_document_form': '',
+            'id': 1,
+        })
+        self.assertEqual(response.status_code, 200)
+
+
+class TestSendForApprovalView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_send_for_approval_fails_when_project_is_locked(self):
+        project = ProjectFactory(is_locked=True)
+
+        # The view doesn't have any custom changes when form validation fails
+        # so check that directly.
+        form = SetPendingForm(instance=project)
+        self.assertFalse(form.is_valid())
+
+    def test_send_for_approval_fails_when_project_is_not_in_committed_state(self):
+        project = ProjectFactory(status='in_progress')
+
+        # The view doesn't have any custom changes when form validation fails
+        # so check that directly.
+        form = SetPendingForm(instance=project)
+        self.assertFalse(form.is_valid())
+
+    def test_send_for_approval_happy_path(self):
+        project = ProjectFactory(is_locked=False, status='committed')
+
+        response = self.post_page(project, {'form-submitted-request_approval_form': ''})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        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)
+
+
+class BaseProjectEditTestCase(BaseViewTestCase):
+    url_name = 'funds:projects:{}'
+    base_view_name = 'edit'
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+
+class TestUserProjectEditView(BaseProjectEditTestCase):
+    user_factory = UserFactory
+
+    def test_does_not_have_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 403)
+
+    def test_owner_has_access(self):
+        project = ProjectFactory(user=self.user)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_no_lead_redirects(self):
+        project = ProjectFactory(user=self.user, lead=None)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertRedirects(response, self.url(project, 'detail'))
+
+    def test_locked_redirects(self):
+        project = ProjectFactory(user=self.user, is_locked=True)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertRedirects(response, self.url(project, 'detail'))
+
+
+class TestStaffProjectEditView(BaseProjectEditTestCase):
+    user_factory = StaffFactory
+
+    def test_staff_user_has_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_no_lead_redirects(self):
+        project = ProjectFactory(user=self.user, lead=None)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertRedirects(response, self.url(project, 'detail'))
+
+    def test_locked_redirects(self):
+        project = ProjectFactory(user=self.user, is_locked=True)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertRedirects(response, self.url(project, 'detail'))
+
+
+class TestApproverProjectEditView(BaseProjectEditTestCase):
+    user_factory = ApproverFactory
+
+    def test_approver_has_access_locked(self):
+        project = ProjectFactory(is_locked=True)
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestSuperProjectEditView(BaseProjectEditTestCase):
+    user_factory = StaffFactory
+
+    def test_has_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestReviewerProjectEditView(BaseProjectEditTestCase):
+    user_factory = ReviewerFactory
+
+    def test_does_not_have_access(self):
+        project = ProjectFactory()
+        response = self.get_page(project)
+
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class BasePacketFileViewTestCase(BaseViewTestCase):
+    url_name = 'funds:projects:{}'
+    base_view_name = 'document'
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'file_pk': instance.id,
+        }
+
+
+class TestStaffPacketView(BasePacketFileViewTestCase):
+    user_factory = StaffFactory
+
+    def test_staff_can_access(self):
+        document = PacketFileFactory()
+        response = self.get_page(document)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestUserPacketView(BasePacketFileViewTestCase):
+    user_factory = ApplicantFactory
+
+    def test_owner_can_access(self):
+        document = PacketFileFactory(project__user=self.user)
+        response = self.get_page(document)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(response.redirect_chain, [])
+
+    def test_user_can_not_access(self):
+        document = PacketFileFactory()
+        response = self.get_page(document)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.redirect_chain, [])
+
+
+class TestAnonPacketView(BasePacketFileViewTestCase):
+    user_factory = AnonymousUser
+
+    def test_anonymous_can_not_access(self):
+        document = PacketFileFactory()
+        response = self.get_page(document)
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(len(response.redirect_chain), 2)
+        for path, _ in response.redirect_chain:
+            self.assertIn(reverse('users_public:login'), path)
diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3ec5b4485357925701ee72aa5084d631c8ceda4
--- /dev/null
+++ b/opentech/apply/projects/urls.py
@@ -0,0 +1,17 @@
+from django.conf import settings
+from django.urls import include, path
+
+from .views import ProjectDetailView, ProjectEditView, ProjectPrivateMediaView
+
+app_name = 'projects'
+
+urlpatterns = []
+
+if settings.PROJECTS_ENABLED:
+    urlpatterns = [
+        path('<int:pk>/', include([
+            path('', ProjectDetailView.as_view(), name='detail'),
+            path('edit/', ProjectEditView.as_view(), name="edit"),
+            path('documents/<int:file_pk>/', ProjectPrivateMediaView.as_view(), name="document"),
+        ])),
+    ]
diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..812fee430e921d6fcb3e7ad812bad6a531000799
--- /dev/null
+++ b/opentech/apply/projects/views.py
@@ -0,0 +1,261 @@
+from copy import copy
+
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.decorators import method_decorator
+from django.utils.translation import ugettext_lazy as _
+from django.views.generic import CreateView, DetailView, FormView, UpdateView
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.activity.views import ActivityContextMixin, CommentFormView
+from opentech.apply.users.decorators import staff_required
+from opentech.apply.utils.storage import PrivateMediaView
+from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin,
+                                        ViewDispatcher)
+
+from .forms import (
+    CreateApprovalForm,
+    ProjectApprovalForm,
+    ProjectEditForm,
+    RejectionForm,
+    RemoveDocumentForm,
+    SetPendingForm,
+    UpdateProjectLeadForm,
+    UploadDocumentForm,
+)
+from .models import CONTRACTING, Approval, Project, PacketFile
+
+
+@method_decorator(staff_required, name='dispatch')
+class CreateApprovalView(DelegatedViewMixin, CreateView):
+    context_name = 'add_approval_form'
+    form_class = CreateApprovalForm
+    model = Approval
+
+    @transaction.atomic()
+    def form_valid(self, form):
+        project = self.kwargs['object']
+        form.instance.project = project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.APPROVE_PROJECT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+        )
+
+        project.is_locked = False
+        project.status = CONTRACTING
+        project.save(update_fields=['is_locked', 'status'])
+
+        return response
+
+
+@method_decorator(staff_required, name='dispatch')
+class RejectionView(DelegatedViewMixin, UpdateView):
+    context_name = 'rejection_form'
+    form_class = RejectionForm
+    model = Project
+
+    def form_valid(self, form):
+        messenger(
+            MESSAGES.REQUEST_PROJECT_CHANGE,
+            request=self.request,
+            user=self.request.user,
+            source=self.object,
+            comment=form.cleaned_data['comment'],
+        )
+
+        self.object.is_locked = False
+        self.object.save(update_fields=['is_locked'])
+
+        return redirect(self.object)
+
+
+@method_decorator(staff_required, name='dispatch')
+class RemoveDocumentView(DelegatedViewMixin, FormView):
+    context_name = 'remove_document_form'
+    form_class = RemoveDocumentForm
+    model = Project
+
+    def form_valid(self, form):
+        document_id = form.cleaned_data["id"]
+        project = self.kwargs['object']
+
+        try:
+            project.packet_files.get(pk=document_id).delete()
+        except PacketFile.DoesNotExist:
+            pass
+
+        return redirect(project)
+
+
+@method_decorator(staff_required, name='dispatch')
+class SendForApprovalView(DelegatedViewMixin, UpdateView):
+    context_name = 'request_approval_form'
+    form_class = SetPendingForm
+    model = Project
+
+    def form_valid(self, form):
+        # lock project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.SEND_FOR_APPROVAL,
+            request=self.request,
+            user=self.request.user,
+            source=self.object,
+        )
+
+        return response
+
+
+@method_decorator(staff_required, name='dispatch')
+class UpdateLeadView(DelegatedViewMixin, UpdateView):
+    model = Project
+    form_class = UpdateProjectLeadForm
+    context_name = 'lead_form'
+
+    def form_valid(self, form):
+        # Fetch the old lead from the database
+        old = copy(self.get_object())
+
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PROJECT_LEAD,
+            request=self.request,
+            user=self.request.user,
+            source=form.instance,
+            related=old.lead or 'Unassigned',
+        )
+
+        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,
+        CreateApprovalView,
+        RejectionView,
+        RemoveDocumentView,
+        SendForApprovalView,
+        UpdateLeadView,
+        UploadDocumentView,
+    ]
+    model = Project
+    template_name_suffix = '_admin_detail'
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['approvals'] = self.object.approvals.distinct('by')
+        context['remaining_document_categories'] = list(self.object.get_missing_document_categories())
+        return context
+
+
+class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, DetailView):
+    form_views = [
+        CommentFormView,
+    ]
+
+    model = Project
+    template_name_suffix = '_applicant_detail'
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        # This view is only for applicants.
+        if project.user != request.user:
+            raise PermissionDenied
+        return super().dispatch(request, *args, **kwargs)
+
+
+@method_decorator(login_required, name='dispatch')
+class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        project_pk = self.kwargs['pk']
+        self.project = get_object_or_404(Project, pk=project_pk)
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        document = PacketFile.objects.get(pk=kwargs['file_pk'])
+        if document.project != self.project:
+            raise Http404
+        return document.document
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.project.user:
+            return True
+
+        return False
+
+
+class ProjectDetailView(ViewDispatcher):
+    admin_view = AdminProjectDetailView
+    applicant_view = ApplicantProjectDetailView
+
+
+class ProjectApprovalEditView(UpdateView):
+    form_class = ProjectApprovalForm
+    model = Project
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        if not project.editable_by(request.user):
+            messages.info(self.request, _('You are not allowed to edit the project at this time'))
+            return redirect(project)
+        return super().dispatch(request, *args, **kwargs)
+
+
+class ApplicantProjectEditView(UpdateView):
+    form_class = ProjectEditForm
+    model = Project
+
+    def dispatch(self, request, *args, **kwargs):
+        project = self.get_object()
+        # This view is only for applicants.
+        if project.user != request.user:
+            raise PermissionDenied
+
+        if not project.editable_by(request.user):
+            messages.info(self.request, _('You are not allowed to edit the project at this time'))
+            return redirect(project)
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+class ProjectEditView(ViewDispatcher):
+    admin_view = ProjectApprovalEditView
+    applicant_view = ApplicantProjectEditView
diff --git a/opentech/apply/projects/wagtail_hooks.py b/opentech/apply/projects/wagtail_hooks.py
new file mode 100644
index 0000000000000000000000000000000000000000..b33974ab8c280ff6e53c4268739dda3350746c3b
--- /dev/null
+++ b/opentech/apply/projects/wagtail_hooks.py
@@ -0,0 +1,6 @@
+from wagtail.contrib.modeladmin.options import modeladmin_register
+
+from .admin import ManageAdminGoup
+
+
+modeladmin_register(ManageAdminGoup)
diff --git a/opentech/apply/review/views.py b/opentech/apply/review/views.py
index 15fe39ba7e28dad31b7826e452353713061ac48f..ffadaa5567327c7a60e7a6296646aec9da9da4e5 100644
--- a/opentech/apply/review/views.py
+++ b/opentech/apply/review/views.py
@@ -92,7 +92,7 @@ class ReviewEditView(UserPassesTestMixin, BaseStreamForm, UpdateView):
             MESSAGES.EDIT_REVIEW,
             user=self.request.user,
             request=self.request,
-            submission=review.submission,
+            source=review.submission,
             related=review,
         )
         response = super().form_valid(form)
@@ -159,7 +159,7 @@ class ReviewCreateOrUpdateView(BaseStreamForm, CreateOrUpdateView):
                 MESSAGES.NEW_REVIEW,
                 request=self.request,
                 user=self.request.user,
-                submission=self.submission,
+                source=self.submission,
                 related=self.object,
             )
 
@@ -299,7 +299,7 @@ class ReviewOpinionFormView(UserPassesTestMixin, CreateView):
             MESSAGES.REVIEW_OPINION,
             request=self.request,
             user=self.request.user,
-            submission=self.review.submission,
+            source=self.review.submission,
             related=opinion,
         )
 
@@ -399,7 +399,7 @@ class ReviewDeleteView(UserPassesTestMixin, DeleteView):
             MESSAGES.DELETE_REVIEW,
             user=request.user,
             request=request,
-            submission=review.submission,
+            source=review.submission,
             related=review,
         )
         response = super().delete(request, *args, **kwargs)
diff --git a/opentech/apply/stream_forms/files.py b/opentech/apply/stream_forms/files.py
index 8fdf2febd2906c7baf65f19efe2f9dcb45db596e..214b12c2623b31eb94be9113520503b8809559fb 100644
--- a/opentech/apply/stream_forms/files.py
+++ b/opentech/apply/stream_forms/files.py
@@ -16,15 +16,28 @@ class StreamFieldDataEncoder(DjangoJSONEncoder):
 
 
 class StreamFieldFile(File):
-    def __init__(self, *args, filename=None, storage=default_storage, **kwargs):
+    """
+    Attempts to mimic the behaviour of the bound fields in django models
+
+    see django.db.models.fields.files for the inspiration
+    """
+    def __init__(self, instance, field, *args, filename=None, storage=default_storage, **kwargs):
         super().__init__(*args, **kwargs)
+        # Field is the wagtail field that the file was uploaded to
+        self.field = field
+        # Instance is the parent model object that created this file object
+        self.instance = instance
         self.storage = storage
-        self.filename = filename or os.path.basename(self.name)
+        self.filename = filename or self.basename
         self._committed = False
 
     def __str__(self):
         return self.filename
 
+    @property
+    def basename(self):
+        return os.path.basename(self.name)
+
     def __eq__(self, other):
         if isinstance(other, File):
             return self.filename == other.filename and self.size == other.size
@@ -79,10 +92,11 @@ class StreamFieldFile(File):
             self.file.open(mode)
         return self
 
-    def save(self, folder):
-        name = self.name
-        if not name.startswith(folder):
-            name = os.path.join(folder, name)
+    def generate_filename(self):
+        return self.name
+
+    def save(self):
+        name = self.generate_filename()
         name = self.storage.generate_filename(name)
         if not self._committed:
             self.name = self.storage.save(name, self.file)
diff --git a/opentech/apply/stream_forms/tests.py b/opentech/apply/stream_forms/tests.py
index a8d37d02de09a7a503431f897d4c7299db54e86a..2a861023ffd8f1b6bb9d7173b2b00c0ed85a0a48 100644
--- a/opentech/apply/stream_forms/tests.py
+++ b/opentech/apply/stream_forms/tests.py
@@ -12,7 +12,7 @@ fake = Faker()
 def make_files(number):
     file_names = [f'{fake.word()}_{i}.pdf' for i in range(number)]
     files = [
-        StreamFieldFile(SimpleUploadedFile(name, b'Some File Content'), filename=name)
+        StreamFieldFile('fake', 'uuid', SimpleUploadedFile(name, b'Some File Content'), filename=name)
         for name in file_names
     ]
     return files
diff --git a/opentech/apply/users/groups.py b/opentech/apply/users/groups.py
index cc9cad3806bf3e4bf2dd4a435da1170dcbb10c0a..0fb0a9f0bff52c703b3e7e42506034fca497784d 100644
--- a/opentech/apply/users/groups.py
+++ b/opentech/apply/users/groups.py
@@ -4,6 +4,7 @@ REVIEWER_GROUP_NAME = 'Reviewer'
 TEAMADMIN_GROUP_NAME = 'Team Admin'
 PARTNER_GROUP_NAME = 'Partner'
 COMMUNITY_REVIEWER_GROUP_NAME = 'Community reviewer'
+APPROVER_GROUP_NAME = 'Approver'
 
 GROUPS = [
     {
@@ -30,4 +31,8 @@ GROUPS = [
         'name': COMMUNITY_REVIEWER_GROUP_NAME,
         'permissions': [],
     },
+    {
+        'name': APPROVER_GROUP_NAME,
+        'permissions': [],
+    }
 ]
diff --git a/opentech/apply/users/migrations/0013_add_approver_group.py b/opentech/apply/users/migrations/0013_add_approver_group.py
new file mode 100644
index 0000000000000000000000000000000000000000..54f9f968f7acf0efcbb62232970719a4ac83517f
--- /dev/null
+++ b/opentech/apply/users/migrations/0013_add_approver_group.py
@@ -0,0 +1,43 @@
+# Generated by Django 2.0.13 on 2019-08-05 13:12
+
+from django.core.exceptions import ObjectDoesNotExist
+from django.core.management.sql import emit_post_migrate_signal
+from django.db import migrations
+
+from opentech.apply.users.groups import APPROVER_GROUP_NAME, GROUPS
+
+
+def add_groups(apps, schema_editor):
+    # Workaround for https://code.djangoproject.com/ticket/23422
+    db_alias = schema_editor.connection.alias
+    emit_post_migrate_signal(2, False, db_alias)
+
+    Group = apps.get_model('auth.Group')
+    Permission = apps.get_model('auth.Permission')
+
+    for group_data in GROUPS:
+        group, created = Group.objects.get_or_create(name=group_data['name'])
+        for codename in group_data['permissions']:
+            try:
+                permission = Permission.objects.get(codename=codename)
+            except ObjectDoesNotExist:
+                print(f"Could not find the '{permission}' permission")
+                continue
+
+            group.permissions.add(permission)
+
+
+def remove_groups(apps, schema_editor):
+    Group = apps.get_model('auth.Group')
+    Group.objects.filter(name=APPROVER_GROUP_NAME).delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0012_set_applicant_group'),
+    ]
+
+    operations = [
+        migrations.RunPython(add_groups, remove_groups)
+    ]
diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py
index 9609eb97e1ebfdf0d7b2b5b866f828088fba333b..768b583c958865dcd2851c39fa5ffeb686d3845b 100644
--- a/opentech/apply/users/models.py
+++ b/opentech/apply/users/models.py
@@ -1,11 +1,14 @@
-from django.db import models
-from django.db.models import Q
 from django.contrib.auth.hashers import make_password
 from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
+from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
-from .groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME, PARTNER_GROUP_NAME, COMMUNITY_REVIEWER_GROUP_NAME
+
+from .groups import (APPLICANT_GROUP_NAME, APPROVER_GROUP_NAME,
+                     COMMUNITY_REVIEWER_GROUP_NAME, PARTNER_GROUP_NAME,
+                     REVIEWER_GROUP_NAME, STAFF_GROUP_NAME)
 from .utils import send_activation_email
 
 
@@ -27,6 +30,9 @@ class UserQuerySet(models.QuerySet):
     def applicants(self):
         return self.filter(groups__name=APPLICANT_GROUP_NAME)
 
+    def approvers(self):
+        return self.filter(groups__name=APPROVER_GROUP_NAME)
+
 
 class UserManager(BaseUserManager.from_queryset(UserQuerySet)):
     use_in_migrations = True
@@ -132,6 +138,10 @@ class User(AbstractUser):
     def is_applicant(self):
         return self.groups.filter(name=APPLICANT_GROUP_NAME).exists()
 
+    @cached_property
+    def is_approver(self):
+        return self.groups.filter(name=APPROVER_GROUP_NAME).exists()
+
     class Meta:
         ordering = ('full_name', 'email')
 
diff --git a/opentech/apply/users/tests/factories.py b/opentech/apply/users/tests/factories.py
index ea11d35ec695ddcbff5333b98b14f357ad616271..0fb5bee111ed2029b74d7fd4b48c2285be66fe88 100644
--- a/opentech/apply/users/tests/factories.py
+++ b/opentech/apply/users/tests/factories.py
@@ -6,7 +6,12 @@ from django.utils.text import slugify
 
 import factory
 
-from ..groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME
+from ..groups import (
+    APPLICANT_GROUP_NAME,
+    APPROVER_GROUP_NAME,
+    REVIEWER_GROUP_NAME,
+    STAFF_GROUP_NAME,
+)
 
 
 class GroupFactory(factory.DjangoModelFactory):
@@ -60,6 +65,16 @@ class StaffFactory(OAuthUserFactory):
             self.groups.add(GroupFactory(name=STAFF_GROUP_NAME))
 
 
+class ApproverFactory(StaffFactory):
+    @factory.post_generation
+    def groups(self, create, extracted, **kwargs):
+        if create:
+            self.groups.add(
+                GroupFactory(name=STAFF_GROUP_NAME),
+                GroupFactory(name=APPROVER_GROUP_NAME),
+            )
+
+
 class SuperUserFactory(StaffFactory):
     is_superuser = True
 
diff --git a/opentech/apply/utils/image.py b/opentech/apply/utils/image.py
index 5086fb3f658fbab73318be5b8804b40dacd9e3df..93cfdc5fb13089cedc9ea822601c2a3dcf0cef31 100644
--- a/opentech/apply/utils/image.py
+++ b/opentech/apply/utils/image.py
@@ -1,12 +1,22 @@
+from django.core.cache import cache
 from django.urls import reverse
 from django.utils.html import format_html
 
 
+def image_url_cache_key(image_id, spec):
+    return f'image_url_cache_{image_id}_{spec}'
+
+
 def generate_image_url(image, filter_spec):
+    cache_key = image_url_cache_key(image.id, filter_spec)
+    url = cache.get(cache_key)
+    if url:
+        return url
     from wagtail.images.views.serve import generate_signature
     signature = generate_signature(image.id, filter_spec)
     url = reverse('wagtailimages_serve', args=(signature, image.id, filter_spec))
     url += image.file.name[len('original_images/'):]
+    cache.set(cache_key, url)
     return url
 
 
diff --git a/opentech/apply/utils/storage.py b/opentech/apply/utils/storage.py
new file mode 100644
index 0000000000000000000000000000000000000000..5e257d3598f360b0607cbcb718e8df4eed976cd3
--- /dev/null
+++ b/opentech/apply/utils/storage.py
@@ -0,0 +1,41 @@
+import mimetypes
+import os
+from wsgiref.util import FileWrapper
+
+from django.conf import settings
+from django.core.files.storage import get_storage_class
+from django.http import StreamingHttpResponse
+from django.views.generic import View
+
+
+private_file_storage = getattr(settings, 'PRIVATE_FILE_STORAGE', None)
+PrivateStorage = get_storage_class(private_file_storage)
+
+
+class PrivateMediaView(View):
+    storage = PrivateStorage()
+
+    def get_media(self, *args, **kwargs):
+        # Convert the URL request to a path which the storage can use to find the file
+        raise NotImplementedError()
+
+    def get(self, *args, **kwargs):
+        file_to_serve = self.get_media(*args, **kwargs)
+        wrapper = FileWrapper(file_to_serve)
+        encoding_map = {
+            'bzip2': 'application/x-bzip',
+            'gzip': 'application/gzip',
+            'xz': 'application/x-xz',
+        }
+        file_name = os.path.basename(file_to_serve.name)
+        content_type, encoding = mimetypes.guess_type(file_name)
+        # Encoding isn't set to prevent browsers from automatically uncompressing files.
+        content_type = encoding_map.get(encoding, content_type)
+        content_type = content_type or 'application/octet-stream'
+        # From Django 2.1, we can use FileResponse instead of StreamingHttpResponse
+        response = StreamingHttpResponse(wrapper, content_type=content_type)
+
+        response['Content-Disposition'] = f'filename={file_name}'
+        response['Content-Length'] = file_to_serve.size
+
+        return response
diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py
index bba277b3d117141767a458d942b252b9a2cd63b6..2940aceb26b441b25726c68255964da9d9f1db54 100644
--- a/opentech/apply/utils/testing/tests.py
+++ b/opentech/apply/utils/testing/tests.py
@@ -29,7 +29,8 @@ class BaseViewTestCase(TestCase):
         super().setUpTestData()
 
     def setUp(self):
-        self.client.force_login(self.user)
+        if not self.user.is_anonymous:
+            self.client.force_login(self.user)
         self.factory = RequestFactory()
 
     def get_kwargs(self, instance):
diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py
index 95e1509ee3007ba05058074a28a8c1bbe579d200..6ea0fb1673f27e375ac389147740534bf6a3ed73 100644
--- a/opentech/apply/utils/views.py
+++ b/opentech/apply/utils/views.py
@@ -96,7 +96,7 @@ class DelegateableView(DelegatableBase):
     def post(self, request, *args, **kwargs):
         self.object = self.get_object()
 
-        kwargs['submission'] = self.object
+        kwargs['object'] = self.object
 
         return super().post(request, *args, **kwargs)
 
diff --git a/opentech/settings/base.py b/opentech/settings/base.py
index 7d2b0b3855d523850e3d37d7e1b63387f27e1a9c..95637252a885211a5508b7b8832bb20762c5af1c 100644
--- a/opentech/settings/base.py
+++ b/opentech/settings/base.py
@@ -77,6 +77,7 @@ INSTALLED_APPS = [
     'opentech.apply.determinations',
     'opentech.apply.stream_forms',
     'opentech.apply.utils',
+    'opentech.apply.projects.apps.ProjectsConfig',
 
     'opentech.public.funds',
     'opentech.public.home',
@@ -184,6 +185,7 @@ TEMPLATES = [
                 'opentech.public.utils.context_processors.global_vars',
                 'social_django.context_processors.backends',
                 'social_django.context_processors.login_redirect',
+                'opentech.apply.projects.context_processors.projects_enabled',
             ],
         },
     },
@@ -626,3 +628,9 @@ REST_FRAMEWORK = {
         'rest_framework.permissions.IsAuthenticated',
     )
 }
+
+
+# Projects Feature Flag
+PROJECTS_ENABLED = False
+if env.get('PROJECTS_ENABLED', 'false').lower().strip() == 'true':
+    PROJECTS_ENABLED = True
diff --git a/opentech/settings/dev.py b/opentech/settings/dev.py
index b6fd212bc1fa13d4ba601106914e106ea8906468..01e2301e5edaf0e08dfaff107651eaab54c39f06 100644
--- a/opentech/settings/dev.py
+++ b/opentech/settings/dev.py
@@ -34,6 +34,8 @@ try:
 except ImportError:
     pass
 
+PROJECTS_ENABLED = True
+
 # We add these here so they can react on settings made in local.py.
 
 # E-mail to local files.
diff --git a/opentech/settings/test.py b/opentech/settings/test.py
index 11a71f78e5248d8568b15eaf0f8d0ca00ff70acd..3d6102ba8bff38edc37cef28a368432054de92c1 100644
--- a/opentech/settings/test.py
+++ b/opentech/settings/test.py
@@ -8,6 +8,8 @@ from .base import *  # noqa
 
 SECRET_KEY = 'NOT A SECRET'
 
+PROJECTS_ENABLED = True
+
 # Need this to ensure white noise doesn't kill the speed of testing
 # http://whitenoise.evans.io/en/latest/django.html#whitenoise-makes-my-tests-run-slow
 WHITENOISE_AUTOREFRESH = True
diff --git a/opentech/static_src/src/javascript/apply/tabs.js b/opentech/static_src/src/javascript/apply/tabs.js
index 27e6a900499855ef1f662334ca5dcfd0b4a5dbea..29ebab1030290dec3f8875b97d37d1ef3e3c4639 100644
--- a/opentech/static_src/src/javascript/apply/tabs.js
+++ b/opentech/static_src/src/javascript/apply/tabs.js
@@ -56,9 +56,10 @@
         updateTabOnLoad() {
             // Find tab with matching hash and activate
             const url = document.location.toString();
-            const match = this.findTab(url.split('#')[1]);
+            const tabName = url.split('#')[1];
+            const match = this.findTab(tabName);
 
-            this.addTabClasses(match);
+            return tabName ? this.addTabClasses(match) : this.addTabClasses(null);
         }
 
         tabs(e) {
diff --git a/opentech/static_src/src/javascript/apply/toggle-payment-block.js b/opentech/static_src/src/javascript/apply/toggle-payment-block.js
new file mode 100644
index 0000000000000000000000000000000000000000..603c10644aa686a31dcf5fb0003d8cf17a81d2ea
--- /dev/null
+++ b/opentech/static_src/src/javascript/apply/toggle-payment-block.js
@@ -0,0 +1,17 @@
+(function ($) {
+
+    'use strict';
+
+    function togglePaymentBlock() {
+        $('.js-payment-block-rejected-link').click(function (e) {
+            e.preventDefault();
+
+            this.innerHTML = (this.innerHTML === 'Show rejected') ? 'Hide rejected' : 'Show rejected';
+
+            $('.js-payment-block-rejected-table').toggleClass('is-hidden');
+        });
+    }
+
+    togglePaymentBlock();
+
+})(jQuery);
diff --git a/opentech/static_src/src/javascript/apply/toggle-proposal-info.js b/opentech/static_src/src/javascript/apply/toggle-proposal-info.js
new file mode 100644
index 0000000000000000000000000000000000000000..c877874ff57725497e7f3fc91f4c2559fc78bedb
--- /dev/null
+++ b/opentech/static_src/src/javascript/apply/toggle-proposal-info.js
@@ -0,0 +1,24 @@
+(function ($) {
+
+    'use strict';
+
+    function toggleProposalInfo() {
+        $('.js-toggle-propsoal-info').click(function (e) {
+            e.preventDefault();
+            const activeClass = 'is-open';
+
+            if (this.innerHTML === 'Show more') {
+                this.innerHTML = 'Hide';
+            }
+            else {
+                this.innerHTML = 'Show more';
+            }
+
+            $(this).toggleClass(activeClass);
+            $('.js-rich-text-hidden').toggleClass(activeClass);
+        });
+    }
+
+    toggleProposalInfo();
+
+})(jQuery);
diff --git a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
index 2e12efb8da5cfd2a96acf318a922a9f07ed25e87..13f5230c23f064fca76588186ded37ac3d002c9b 100644
--- a/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
+++ b/opentech/static_src/src/sass/apply/components/_all-submissions-table.scss
@@ -45,6 +45,12 @@
                     padding-right: 0;
                 }
             }
+
+            &.comments {
+                @include media-query($table-breakpoint) {
+                    width: 110px;
+                }
+            }
         }
 
         tr {
@@ -158,6 +164,14 @@
                 }
             }
 
+            &.fund,
+            &.round,
+            &.screening_status { // sass-lint:disable-line class-name-format
+                -webkit-hyphens: auto; // sass-lint:disable-line no-vendor-prefixes
+                -ms-hyphens: auto; // sass-lint:disable-line no-vendor-prefixes
+                hyphens: auto;
+            }
+
             // arrow to toggle project info - added via js
             @include media-query($table-breakpoint) {
                 .arrow {
diff --git a/opentech/static_src/src/sass/apply/components/_button.scss b/opentech/static_src/src/sass/apply/components/_button.scss
index 9d7ebc20301a487cd00ae7cd13ea324a02356687..9a1963268d8322077716280ad7bd3c63eb70a028 100644
--- a/opentech/static_src/src/sass/apply/components/_button.scss
+++ b/opentech/static_src/src/sass/apply/components/_button.scss
@@ -133,7 +133,7 @@
 
     &--half-width {
         width: 50%;
-        padding: 10px 0;
+        padding: 10px;
         text-align: center;
         margin-right: 20px;
 
@@ -165,6 +165,7 @@
             right: 15px;
             font-size: 30px;
             content: '+';
+            line-height: 1.2;
         }
 
         &.is-active {
diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..a2d695259d9c84954a6cd946a29054067539dc5e
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss
@@ -0,0 +1,176 @@
+.docs-block {
+    border: 1px solid $color--light-blue;
+
+    @include media-query(tablet-portrait) {
+        max-width: 610px;
+    }
+
+    &__header {
+        background-color: $color--light-blue;
+        color: $color--white;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        padding: 1rem;
+    }
+
+    &__heading {
+        margin: 0;
+    }
+
+    &__inner {
+        padding: 1rem;
+
+        @include media-query(tablet-portrait) {
+            padding: 2rem;
+        }
+    }
+
+    &__row {
+        padding-bottom: 1rem;
+        margin-bottom: 1rem;
+        border-bottom: 1px solid $color--mid-grey;
+
+        @include media-query(tablet-portrait) {
+            padding-bottom: 2rem;
+            margin-bottom: 2rem;
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            flex-wrap: wrap;
+        }
+
+        &:last-child {
+            border-bottom: 0;
+            padding-bottom: 0;
+            margin-bottom: 0;
+        }
+    }
+
+    &__row-inner {
+        display: flex;
+        align-items: center;
+
+        &:first-child {
+            margin-bottom: 1rem;
+        }
+
+        @include media-query(tablet-portrait) {
+            &:first-child {
+                margin: 0 1rem 0 0;
+                flex: 1;
+            }
+        }
+    }
+
+    &__title {
+        margin: 0;
+        font-size: 1.2rem;
+
+        @include media-query(tablet-landscape) {
+            flex: 1;
+            font-size: 1.5rem;
+        }
+    }
+
+    &__icon {
+        width: 20px;
+        height: 20px;
+        stroke: $color--mid-grey;
+        fill: transparent;
+        margin-right: .5rem;
+
+        @include media-query(tablet-landscape) {
+            margin-right: 1rem;
+            width: 30px;
+            height: 30px;
+        }
+
+        &.is-complete {
+            stroke: $color--light-blue;
+        }
+    }
+
+    &__link {
+        font-weight: $weight--bold;
+        margin-right: 1rem;
+
+        &:last-child {
+            margin-right: 0;
+        }
+    }
+
+    &__info-text {
+        @include media-query(tablet-landscape) {
+            max-width: 65%;
+            margin-left: 3rem;
+        }
+    }
+
+    &__buttons {
+        padding: 0 1rem 1rem;
+
+        @include media-query(tablet-portrait) {
+            padding: 0 2rem 2rem;
+        }
+
+        .button {
+            padding: .5rem 1rem;
+            margin: 0 .5rem .5rem 0;
+
+            &:last-child {
+                margin: 0;
+            }
+        }
+    }
+
+    &__document-list {
+        width: 100%;
+        margin-top: 1.5rem;
+        padding-left: 0;
+
+        @include media-query(tablet-landscape) {
+            padding-left: 3rem;
+        }
+    }
+
+    &__document {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        padding-bottom: 1rem;
+        margin: 1rem 0;
+        border-bottom: 1px solid $color--light-mid-grey;
+        flex-wrap: wrap;
+
+        &:last-child {
+            padding-bottom: 0;
+            margin: 0;
+            border-bottom: 0;
+        }
+    }
+
+    &__document-inner {
+        &:first-child {
+            margin: 0 1rem 1rem 0;
+        }
+
+        @include media-query(tablet-portrait) {
+            &:first-child {
+                margin: 0 1rem 0 0;
+            }
+        }
+    }
+
+    &__document-info {
+        margin: 0;
+    }
+
+    &__document-link {
+        margin-right: 1rem;
+
+        &:last-child {
+            margin-right: 0;
+        }
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_funding-block.scss b/opentech/static_src/src/sass/apply/components/_funding-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..8f18deb78f16368a959a87e627cbe8759cc10567
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_funding-block.scss
@@ -0,0 +1,46 @@
+.funding-block {
+    background-color: $color--light-blue;
+    color: $color--white;
+    padding: 1rem;
+    margin-bottom: 1rem;
+
+    @include media-query(mob-landscape) {
+        padding: 2rem;
+        display: flex;
+        justify-content: space-between;
+        flex-wrap: wrap;
+    }
+
+    @include media-query(tablet-portrait) {
+        max-width: 610px;
+    }
+
+    &__item {
+        margin: 0 .5rem 1rem 0;
+
+        @include media-query(mob-landscape) {
+            margin: 0 .5rem 0 0;
+        }
+
+        &:last-child {
+            margin: 0;
+        }
+    }
+
+    &__title {
+        margin: 0;
+    }
+
+    &__standout {
+        margin: 0;
+        font-size: map-get($font-sizes, delta);
+
+        @include media-query(small-tablet) {
+            font-size: map-get($font-sizes, beta);
+        }
+    }
+
+    &__meta {
+        margin: 0;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_grid.scss b/opentech/static_src/src/sass/apply/components/_grid.scss
index f2196f68632d62f65512d353ed5ca064e947c2b1..23e966a9040b3dbf904e86753fd9c696db540877 100644
--- a/opentech/static_src/src/sass/apply/components/_grid.scss
+++ b/opentech/static_src/src/sass/apply/components/_grid.scss
@@ -62,7 +62,10 @@
             .wrapper--comments & {
                 max-width: 800px;
                 margin-bottom: 0;
-                grid-template-columns: repeat(auto-fit,  200px 200px);
+
+                @include media-query(mob-landscape) {
+                    grid-template-columns: repeat(auto-fit,  200px 200px);
+                }
             }
 
             + br {
@@ -75,14 +78,12 @@
         }
 
         &--proposal-info {
-            padding-bottom: 30px;
-            margin: 0 0 30px;
-            border-bottom: 1px solid $color--mid-grey;
             grid-template-columns: 100%;
-            grid-gap: 10px; // sass-lint:disable-line no-misspelled-properties
+            grid-gap: 10px;
+            margin: 0 0 1rem;
 
             @include media-query(mob-landscape) {
-                margin: 0 0 30px;
+                margin: 0 0 1.5rem;
                 grid-template-columns: 1fr 1fr;
             }
 
diff --git a/opentech/static_src/src/sass/apply/components/_invoice-block.scss b/opentech/static_src/src/sass/apply/components/_invoice-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..d0bc7af63f87d31c016c72d8b0e7566df6416a0e
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_invoice-block.scss
@@ -0,0 +1,36 @@
+.invoice-block {
+    padding: 1rem;
+    margin-bottom: 1rem;
+    border: 1px solid $color--dark-blue;
+
+    @include media-query(mob-landscape) {
+        display: flex;
+        justify-content: space-between;
+        padding: 2rem;
+    }
+
+    @include media-query(tablet-portrait) {
+        max-width: 610px;
+    }
+
+    &__item {
+        margin-bottom: 1rem;
+
+        &:last-child {
+            margin: 0;
+        }
+
+        @include media-query(mob-landscape) {
+            margin: 0 1rem 0 0;
+        }
+    }
+
+    &__title {
+        font-weight: $weight--bold;
+        margin: 0 0 .2rem;
+    }
+
+    &__meta {
+        margin: 0;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_link.scss b/opentech/static_src/src/sass/apply/components/_link.scss
index 35e89799fd7bd152bf6f498ca47b9ac1b828e1aa..195c1ca543f9b2a72ef2888ba5f1cb489562be2b 100644
--- a/opentech/static_src/src/sass/apply/components/_link.scss
+++ b/opentech/static_src/src/sass/apply/components/_link.scss
@@ -133,11 +133,13 @@
                 height: 30px;
                 margin-left: 30px;
                 font-size: 30px;
-                line-height: .9;
-                text-align: center;
                 border: 2px solid $color--white;
                 border-radius: 50%;
                 content: '+';
+                display: flex;
+                align-items: center;
+                justify-content: center;
+                padding-bottom: 4px;
             }
         }
 
@@ -166,11 +168,13 @@
             height: 30px;
             margin-left: 10px;
             font-size: 30px;
-            line-height: .1;
-            text-align: center;
             border: 2px solid $color--white;
             border-radius: 50%;
-            content: '_';
+            content: '\2013';
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            padding-bottom: 5px;
 
             @include media-query(tablet-portrait) {
                 margin-left: 20px;
@@ -212,10 +216,6 @@
             cursor: not-allowed;
         }
 
-        @include media-query(tablet-landscape) {
-            margin-left: auto;
-        }
-
         &.is-active {
             color: $color--light-blue;
 
@@ -225,8 +225,39 @@
         }
     }
 
+    &--delete-submission {
+        margin-right: 1rem;
+        padding-right: 1rem;
+        border-right: 2px solid $color--mid-grey;
+
+        &:only-child {
+            border-right: 0;
+            padding-right: 0;
+            margin-right: 0;
+        }
+    }
+
     &--toggle-reviewers {
         display: block;
         margin: 0 10px 30px;
     }
+
+    &--reveal-proposal {
+        display: flex;
+        align-items: center;
+        margin: 0 0 1rem;
+
+        &::before {
+            @include triangle(top, $color--dark-blue, 7px);
+            margin-right: .7rem;
+            transition: transform $transition;
+            transform: rotate(180deg);
+        }
+
+        &.is-open {
+            &::before {
+                transform: rotate(0);
+            }
+        }
+    }
 }
diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..1cb21d1282882f263f601bfdaecb3f2b260936b3
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss
@@ -0,0 +1,157 @@
+.payment-block {
+    border: 1px solid $color--mid-grey;
+    padding: 1rem;
+    margin-bottom: 1rem;
+
+    @include media-query(mob-landscape) {
+        padding: 2rem;
+    }
+
+    @include media-query(tablet-portrait) {
+        max-width: 610px;
+    }
+
+    &__header {
+        margin-bottom: 1rem;
+
+        @include media-query(tablet-portrait) {
+            margin-bottom: 1.5rem;
+        }
+
+        @include media-query(mob-landscape) {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+    }
+
+    &__title {
+        font-size: map-get($font-sizes, delta);
+        margin: 0 0 1rem;
+
+        @include media-query(mob-landscape) {
+            margin: 0;
+        }
+    }
+
+    &__button {
+        padding: .7rem 1.2rem;
+    }
+
+    &__status {
+        margin: 0;
+
+        @include media-query(tablet-landscape) {
+            display: block;
+            font-weight: $weight--bold;
+        }
+    }
+
+    &__status-link {
+        font-size: map-get($font-sizes, milli);
+    }
+
+    &__rejected {
+        text-align: center;
+    }
+
+    &__rejected-link {
+        font-weight: $weight--bold;
+    }
+
+    &__mobile-label {
+        display: inline-block;
+        font-weight: $weight--bold;
+        white-space: pre;
+
+        @include media-query(tablet-landscape) {
+            display: none;
+        }
+    }
+
+    &__table {
+        thead {
+            display: none;
+            border-top: 2px solid $color--light-mid-grey;
+
+            @include media-query(tablet-landscape) {
+                display: table-header-group;
+            }
+
+            th {
+                color: $color--mid-dark-grey;
+                padding: 10px;
+
+                @include media-query(tablet-landscape) {
+                    text-align: center;
+                }
+            }
+
+            tr {
+                border-color: $color--light-mid-grey;
+            }
+        }
+
+        tbody {
+            font-size: map-get($font-sizes, zeta);
+
+            a {
+                text-decoration: underline;
+            }
+        }
+
+        tr {
+            border: 0;
+            border-bottom: 2px solid $color--light-grey;
+
+            &:hover {
+                box-shadow: none;
+            }
+
+            td {
+                padding: 0 0 .5rem;
+                word-break: break-word;
+
+                &:first-child {
+                    padding: 1rem 0 .5rem;
+
+                    @include media-query(tablet-landscape) {
+                        padding: 1rem;
+                    }
+                }
+
+                &:last-child {
+                    padding: 0 0 1rem;
+
+                    @include media-query(tablet-landscape) {
+                        padding: 1rem;
+                    }
+                }
+
+                @include media-query(tablet-landscape) {
+                    padding: 1rem;
+                }
+            }
+        }
+    }
+
+    &__table-amount {
+        width: 20%;
+        min-width: 90px;
+    }
+
+    &__table-status {
+        min-width: 160px;
+        width: 35%;
+    }
+
+    &__table-docs {
+        min-width: 180px;
+        width: 20%;
+    }
+
+    &__table-update {
+        min-width: 160px;
+        width: 25%;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_related-sidebar.scss b/opentech/static_src/src/sass/apply/components/_related-sidebar.scss
index 582ebe17f3c0863574f7b8dd8b4b31f57e87b254..feb8eb4bbc363b111ff9f9b07e1ccdec1b757434 100644
--- a/opentech/static_src/src/sass/apply/components/_related-sidebar.scss
+++ b/opentech/static_src/src/sass/apply/components/_related-sidebar.scss
@@ -1,6 +1,7 @@
 .related-sidebar {
     ul {
         list-style-type: circle;
+        padding-left: 20px;
     }
 
     &--collaps {
diff --git a/opentech/static_src/src/sass/apply/components/_rich-text.scss b/opentech/static_src/src/sass/apply/components/_rich-text.scss
index 057a3d218b9ef1e905bf9b276cfa75b08c869117..256965df61c030bd864de3f4d120b78ae97e2624 100644
--- a/opentech/static_src/src/sass/apply/components/_rich-text.scss
+++ b/opentech/static_src/src/sass/apply/components/_rich-text.scss
@@ -20,6 +20,14 @@
         }
     }
 
+    &--hidden {
+        display: none;
+
+        &.is-open {
+            display: block;
+        }
+    }
+
     a {
         font-weight: $weight--bold;
         border-bottom: 1px solid transparent;
diff --git a/opentech/static_src/src/sass/apply/components/_stat-block.scss b/opentech/static_src/src/sass/apply/components/_stat-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..7441e11b24797eb6c842174d3e31a0a686f6409d
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_stat-block.scss
@@ -0,0 +1,72 @@
+.stat-block {
+    $root: &;
+
+    @include media-query(tablet-portrait) {
+        display: flex;
+    }
+
+    &__item {
+        border: 1px solid $color--mid-dark-grey;
+        padding: 1.5rem;
+        background-color: $color--white;
+        flex: 1;
+        display: block;
+        margin: 0 0 1rem;
+
+        @include media-query(tablet-portrait) {
+            margin: 0 1rem 0 0;
+            padding: 1.9rem 2.5rem;
+        }
+
+        @include media-query(tablet-landscape) {
+            margin: 0 2rem 0 0;
+        }
+
+        &:last-child {
+            margin-right: 0;
+        }
+
+        &:only-child {
+            @include media-query(tablet-portrait) {
+                max-width: calc(100% / 3);
+            }
+        }
+    }
+
+    &__number {
+        @extend %h1;
+        line-height: 1;
+        margin: 0 0 .7rem;
+        color: $color--marine;
+    }
+
+    &__text {
+        font-weight: $weight--bold;
+        margin: 0 0 1rem;
+        color: $color--dark-grey;
+
+        @include media-query(small-tablet) {
+            margin: 0 0 1.5rem;
+            font-size: map-get($font-sizes, delta);
+        }
+    }
+
+    &__view {
+        text-transform: uppercase;
+        font-weight: $weight--bold;
+        letter-spacing: .5px;
+        transition: opacity $quick-transition;
+
+        @include media-query(small-tablet) {
+            font-size: map-get($font-sizes, epsilon);
+        }
+
+        @include media-query(tablet-portrait) {
+            opacity: 0;
+
+            #{$root}__item:hover & {
+                opacity: 1;
+            }
+        }
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_status-bar.scss b/opentech/static_src/src/sass/apply/components/_status-bar.scss
index f47f20dddf87d71230db88ae2aed25b3e843b77e..d47fea043db4c71d543fecd028779aea741e826f 100644
--- a/opentech/static_src/src/sass/apply/components/_status-bar.scss
+++ b/opentech/static_src/src/sass/apply/components/_status-bar.scss
@@ -185,8 +185,12 @@
             .status-bar__item:last-of-type & {
                 left: -45px;
 
-                @include media-query(desktop) {
-                    left: -25px;
+                @include media-query(tablet-portrait) {
+                    left: -60px;
+                }
+
+                @include media-query(desktop-medium) {
+                    left: -35px;
                 }
             }
 
diff --git a/opentech/static_src/src/sass/apply/components/_status-block.scss b/opentech/static_src/src/sass/apply/components/_status-block.scss
index f7acc3acce0f476492125273479f0a51eff9e9d1..2657866ac39c119d3e051a081eafa85c596c1f28 100644
--- a/opentech/static_src/src/sass/apply/components/_status-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_status-block.scss
@@ -57,6 +57,7 @@
     &__title {
         font-weight: $weight--semibold;
         width: 100%;
+        hyphens: auto;
     }
 
     &__link {
diff --git a/opentech/static_src/src/sass/apply/components/_wrapper.scss b/opentech/static_src/src/sass/apply/components/_wrapper.scss
index e7165e01b4cd95706618d388763e6ce4601e6f5f..1a6c227b6ef04de9ae45227eced7bca259d355cb 100644
--- a/opentech/static_src/src/sass/apply/components/_wrapper.scss
+++ b/opentech/static_src/src/sass/apply/components/_wrapper.scss
@@ -280,4 +280,17 @@
             justify-content: flex-end;
         }
     }
+
+    &--submission-actions {
+        margin-left: auto;
+        display: flex;
+        margin-top: 1rem;
+        align-items: flex-start;
+
+        @include media-query(tablet-landscape) {
+            margin-top: 0;
+            justify-content: flex-end;
+            flex: 1;
+        }
+    }
 }
diff --git a/opentech/static_src/src/sass/apply/fancybox.scss b/opentech/static_src/src/sass/apply/fancybox.scss
index e01a47923d6f75200525d969ec325368fda585f0..086643bb3344899d514d2ecf8f5e1404b108e6d8 100644
--- a/opentech/static_src/src/sass/apply/fancybox.scss
+++ b/opentech/static_src/src/sass/apply/fancybox.scss
@@ -325,10 +325,6 @@ body.fancybox-iosfix {
 	position: relative;
     overflow: visible;
     shape-rendering: geometricPrecision;
-
-	.modal--secondary & {
-		display: none;
-	}
 }
 
 .fancybox-button svg path {
@@ -398,8 +394,8 @@ body.fancybox-iosfix {
 
 .fancybox-close-small {
 	position: absolute;
-	top: 0;
-	right: 0;
+	top: 5px;
+	right: 5px;
 	width: 40px;
 	height: 40px;
 	padding: 0;
@@ -411,32 +407,6 @@ body.fancybox-iosfix {
 	cursor: pointer;
 }
 
-.fancybox-close-small:after {
-	content: '×';
-	position: absolute;
-	top : 5px;
-	right: 5px;
-	width: 30px;
-	height: 30px;
-	font: 22px/30px Arial,"Helvetica Neue",Helvetica,sans-serif;
-	color: #888;
-	font-weight: 300;
-	text-align: center;
-	border-radius: 50%;
-	border-width: 0;
-	background-color: transparent;
-	transition: background-color .25s;
-	box-sizing: border-box;
-	z-index: 2;
-
-	.modal--secondary & {
-		color: white;
-		font-size: 40px;
-		margin-top: 12px;
-    	margin-right: 8px;
-	}
-}
-
 .fancybox-close-small:focus {
 	outline: none;
 }
diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss
index 6a99196364afd0f89d1b8f0dde26b10fc8163f8f..3cb623c756ca2c22160742324637992d989b40ad 100644
--- a/opentech/static_src/src/sass/apply/main.scss
+++ b/opentech/static_src/src/sass/apply/main.scss
@@ -52,6 +52,11 @@
 @import 'components/wrapper';
 @import 'components/revisions';
 @import 'components/messages';
+@import 'components/stat-block';
+@import 'components/docs-block';
+@import 'components/funding-block';
+@import 'components/payment-block';
+@import 'components/invoice-block';
 
 // Layout
 @import 'layout/header';
diff --git a/opentech/storage_backends.py b/opentech/storage_backends.py
index e7f08555901397cda8232239a2bed3d01b289432..4dba8b95e81bd05ce6dc79e6a4932e2e91d68428 100644
--- a/opentech/storage_backends.py
+++ b/opentech/storage_backends.py
@@ -1,7 +1,6 @@
 from urllib import parse
 
 from django.conf import settings
-from django.urls import reverse
 from django.utils.encoding import filepath_to_uri
 from storages.backends.s3boto3 import S3Boto3Storage
 
@@ -29,21 +28,8 @@ class PrivateMediaStorage(S3Boto3Storage):
     file_overwrite = False
     querystring_auth = True
     url_protocol = 'https:'
-    is_submission = False
 
     def url(self, name, parameters=None, expire=None):
-        if self.is_submission:
-            try:
-                name_parts = name.split('/')
-                return reverse(
-                    'apply:submissions:serve_private_media', kwargs={
-                        'submission_id': name_parts[1], 'field_id': name_parts[2],
-                        'file_name': name_parts[3]
-                    }
-                )
-            except IndexError:
-                pass
-
         url = super().url(name, parameters, expire)
 
         if hasattr(settings, 'AWS_PRIVATE_CUSTOM_DOMAIN'):