diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index 57a31e798777408e27d46da929c214689c6ad09f..3c07ee7c97fa62d08f3ff061c909d1630bb3ecbf 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -64,6 +64,10 @@ neat_related = {
     MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request',
     MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request',
     MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request',
+    MESSAGES.SUBMIT_REPORT: 'report',
+    MESSAGES.SKIPPED_REPORT: 'report',
+    MESSAGES.REPORT_FREQUENCY_CHANGED: 'config',
+    MESSAGES.REPORT_NOTIFY: 'report',
 }
 
 
@@ -134,14 +138,14 @@ class AdapterBase:
             event.source.id: event
             for event in events
         }
-        for recipient in self.batch_recipients(message_type, sources, **kwargs):
+        for recipient in self.batch_recipients(message_type, sources, user=user, **kwargs):
             recipients = recipient['recipients']
             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, source, related=None, **kwargs):
-        recipients = self.recipients(message_type, source=source, related=related, **kwargs)
+        recipients = self.recipients(message_type, source=source, related=related, user=user, **kwargs)
         self.process_send(message_type, recipients, [event], request, user, source, related=related, **kwargs)
 
     def process_send(self, message_type, recipients, events, request, user, source, sources=list(), related=None, **kwargs):
@@ -238,6 +242,9 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.APPROVE_CONTRACT: 'Approved contract',
         MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}',
         MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted',
+        MESSAGES.SUBMIT_REPORT: 'Submitted a report',
+        MESSAGES.SKIPPED_REPORT: 'handle_skipped_report',
+        MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency',
     }
 
     def recipients(self, message_type, **kwargs):
@@ -338,6 +345,16 @@ class ActivityAdapter(AdapterBase):
 
         return ' '.join(message)
 
+    def handle_report_frequency(self, config, **kwargs):
+        new_schedule = config.get_frequency_display()
+        return f"Updated reporting frequency. New schedule is: {new_schedule} starting on {config.schedule_start}"
+
+    def handle_skipped_report(self, report, **kwargs):
+        if report.skipped:
+            return "Skipped a Report"
+        else:
+            return "Marked a Report as required"
+
     def send_message(self, message, user, source, sources, **kwargs):
         from .models import Activity
         visibility = kwargs.get('visibility', ALL)
@@ -405,6 +422,7 @@ class SlackAdapter(AdapterBase):
         MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has changed the status of <{link_related}|payment request> on <{link}|{source.title}> to {payment_request.status_display}.',
         MESSAGES.DELETE_PAYMENT_REQUEST: '{user} has deleted payment request from <{link}|{source.title}>.',
         MESSAGES.UPDATE_PAYMENT_REQUEST: '{user} has updated payment request for <{link}|{source.title}>.',
+        MESSAGES.SUBMIT_REPORT: '{user} has submitted a report for <{link}|{source.title}>.'
     }
 
     def __init__(self):
@@ -631,8 +649,12 @@ class EmailAdapter(AdapterBase):
         MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner',
         MESSAGES.UPLOAD_CONTRACT: 'messages/email/contract_uploaded.html',
         MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html',
-        MESSAGES.UPDATE_PAYMENT_REQUEST: 'handle_update_payment_request',
+        MESSAGES.UPDATE_PAYMENT_REQUEST: 'messages/email/payment_request_updated.html',
         MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated',
+        MESSAGES.SUBMIT_REPORT: 'messages/email/report_submitted.html',
+        MESSAGES.SKIPPED_REPORT: 'messages/email/report_skipped.html',
+        MESSAGES.REPORT_FREQUENCY_CHANGED: 'messages/email/report_frequency.html',
+        MESSAGES.REPORT_NOTIFY: 'messages/email/report_notify.html',
     }
 
     def get_subject(self, message_type, source):
@@ -674,15 +696,6 @@ class EmailAdapter(AdapterBase):
             old_phase = transitions[submission.id]
             return self.handle_transition(old_phase=old_phase, source=submission, **kwargs)
 
-    def handle_update_payment_request(self, user, **kwargs):
-        if user.is_applicant:
-            return
-
-        return self.render_message(
-            'messages/email/payment_request_updated.html',
-            **kwargs,
-        )
-
     def handle_payment_status_updated(self, related, **kwargs):
         return self.render_message(
             'messages/email/payment_request_status_updated.html',
@@ -708,7 +721,7 @@ class EmailAdapter(AdapterBase):
         if not comment.priviledged and not comment.user == source.user:
             return self.render_message('messages/email/comment.html', **kwargs)
 
-    def recipients(self, message_type, source, **kwargs):
+    def recipients(self, message_type, source, user, **kwargs):
         if is_ready_for_review(message_type):
             return self.reviewers(source)
 
@@ -731,6 +744,11 @@ class EmailAdapter(AdapterBase):
 
             return [project_settings.compliance_email]
 
+        if message_type in {MESSAGES.SUBMIT_REPORT, MESSAGES.UPDATE_PAYMENT_REQUEST}:
+            # Don't tell the user if they did these activities
+            if user.is_applicant:
+                return []
+
         return [source.user.email]
 
     def batch_recipients(self, message_type, sources, **kwargs):
@@ -770,7 +788,7 @@ class EmailAdapter(AdapterBase):
             return self.render_message('messages/email/partners_update_partner.html', **kwargs)
 
     def render_message(self, template, **kwargs):
-        return render_to_string(template, kwargs)
+        return render_to_string(template, kwargs, kwargs['request'])
 
     def send_message(self, message, source, subject, recipient, logs, **kwargs):
         try:
@@ -803,6 +821,8 @@ class DjangoMessagesAdapter(AdapterBase):
         MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations',
         MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document',
         MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document',
+        MESSAGES.SKIPPED_REPORT: 'handle_skipped_report',
+        MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency',
     }
 
     def batch_reviewers_updated(self, added, sources, **kwargs):
@@ -819,6 +839,16 @@ class DjangoMessagesAdapter(AdapterBase):
             ', '.join(['"{}"'.format(source.title) for source in sources])
         )
 
+    def handle_report_frequency(self, config, **kwargs):
+        new_schedule = config.get_frequency_display()
+        return f"Successfully updated reporting frequency. They will now report {new_schedule} starting on {config.schedule_start}"
+
+    def handle_skipped_report(self, report, **kwargs):
+        if report.skipped:
+            return f"Successfully skipped a Report for {report.start_date} to {report.end_date}"
+        else:
+            return f"Successfully unskipped a Report for {report.start_date} to {report.end_date}"
+
     def batch_transition(self, sources, transitions, **kwargs):
         base_message = 'Successfully updated:'
         transition = '{submission} [{old_display} → {new_display}].'
diff --git a/opentech/apply/activity/migrations/0050_add_submit_report.py b/opentech/apply/activity/migrations/0050_add_submit_report.py
new file mode 100644
index 0000000000000000000000000000000000000000..570a0e32ce1500f100c637294f1f60e126a8dc6f
--- /dev/null
+++ b/opentech/apply/activity/migrations/0050_add_submit_report.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-10-30 14:34
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0049_auto_20191112_1227'),
+    ]
+
+    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'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0051_add_report_skipping_activity.py b/opentech/apply/activity/migrations/0051_add_report_skipping_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..f410ec1db27371659722f6f4e1a1acfbce4759d8
--- /dev/null
+++ b/opentech/apply/activity/migrations/0051_add_report_skipping_activity.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-10-31 16:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0050_add_submit_report'),
+    ]
+
+    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'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report'), ('SKIPPED_REPORT', 'Skipped Report')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0052_report_frequency_change.py b/opentech/apply/activity/migrations/0052_report_frequency_change.py
new file mode 100644
index 0000000000000000000000000000000000000000..e6db41b01524b9b90f92dfef51ccf72f896f1737
--- /dev/null
+++ b/opentech/apply/activity/migrations/0052_report_frequency_change.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-10-31 23:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0051_add_report_skipping_activity'),
+    ]
+
+    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'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report'), ('SKIPPED_REPORT', 'Skipped Report'), ('REPORT_FREQUENCY_CHANGED', 'Report Frequency Changed')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/migrations/0053_nullable_by_report_notify.py b/opentech/apply/activity/migrations/0053_nullable_by_report_notify.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f4facc46b4c414e93c1110eff4f17060d8c23e7
--- /dev/null
+++ b/opentech/apply/activity/migrations/0053_nullable_by_report_notify.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.1.11 on 2019-11-05 16:20
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('activity', '0052_report_frequency_change'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='event',
+            name='by',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+        ),
+        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'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report'), ('SKIPPED_REPORT', 'Skipped Report'), ('REPORT_FREQUENCY_CHANGED', 'Report Frequency Changed'), ('REPORT_NOTIFY', 'Report Notify')], max_length=50),
+        ),
+    ]
diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py
index cf37ef0c242564a3c388305568f8686af32938c2..82b87a65445c2510d139a0696a9e6243a5ccd588 100644
--- a/opentech/apply/activity/models.py
+++ b/opentech/apply/activity/models.py
@@ -148,7 +148,7 @@ 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)
+    by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True)
     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')
diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py
index 7d2f9bb588d82ad1b32fe80a6a9602944adc9362..c6b54450a0f2574c210c78c1a020b0021c23d48d 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -42,6 +42,10 @@ class MESSAGES(Enum):
     DELETE_PAYMENT_REQUEST = 'Delete Payment Request'
     SENT_TO_COMPLIANCE = 'Project was sent to Compliance'
     UPDATE_PAYMENT_REQUEST = 'Updated Payment Request'
+    SUBMIT_REPORT = 'Submit Report'
+    SKIPPED_REPORT = 'Skipped Report'
+    REPORT_FREQUENCY_CHANGED = 'Report Frequency Changed'
+    REPORT_NOTIFY = 'Report Notify'
 
     @classmethod
     def choices(cls):
diff --git a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html
index c00b187bf3c39013a3f70dd7c0b24660491bfd34..e35b4d1f7afda34896c70ed40c8f69e7666a6a05 100644
--- a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html
+++ b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html
@@ -1,7 +1,7 @@
 {% extends "messages/email/applicant_base.html" %}
 
 {% block content %}
-An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
+An {{ ORG_SHORT_NAME }} staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
 It is now {{ payment_request.get_status_display }}.
 
 {% if has_changes_requested %}
diff --git a/opentech/apply/activity/templates/messages/email/payment_request_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_updated.html
index 368b14344ad427b9d478a2c02ed7821d938101a3..a1af1fa8eb92e691ab2fc406b03d713dbc1d7a1b 100644
--- a/opentech/apply/activity/templates/messages/email/payment_request_updated.html
+++ b/opentech/apply/activity/templates/messages/email/payment_request_updated.html
@@ -1,7 +1,7 @@
 {% extends "messages/email/applicant_base.html" %}
 
 {% block content %}
-An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
+An {{ ORG_SHORT_NAME }} staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}.
 It is now {{ payment_request.get_status_display }}.
 
 Title: {{ source.title }}
diff --git a/opentech/apply/activity/templates/messages/email/report_frequency.html b/opentech/apply/activity/templates/messages/email/report_frequency.html
new file mode 100644
index 0000000000000000000000000000000000000000..093142d3267a783ae69915d7d0de2e99939c0dca
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/report_frequency.html
@@ -0,0 +1,13 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+
+An {{ ORG_SHORT_NAME }} staff member has changed the reporting frequency of {{ source.title }}.
+
+The new schedule is: {{ config.get_frequency_display }}
+
+The next report is due: {{ config.current_due_report.end_date }}
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/report_notify.html b/opentech/apply/activity/templates/messages/email/report_notify.html
new file mode 100644
index 0000000000000000000000000000000000000000..104285b756e07a53ac11b581c380d72ce4d4834c
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/report_notify.html
@@ -0,0 +1,10 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+
+A report is due for {{ source.title }} on {{ report.end_date }}.
+
+More information can be found on the project page:
+{{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/report_skipped.html b/opentech/apply/activity/templates/messages/email/report_skipped.html
new file mode 100644
index 0000000000000000000000000000000000000000..36584004a6a55ed10f4b7c587248fade799a29df
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/report_skipped.html
@@ -0,0 +1,13 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+
+An {{ ORG_SHORT_NAME }} staff member has marked a report as {% if report.skipped %}no longer required{% else %}required{% endif %} for {{ source.title }} for period {{ report.end_date }} to {{ report.end_date }}.
+
+{% if not report.skipped %}
+This report was previously not required. Please ensure you now complete the report.
+{% endif %}
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/activity/templates/messages/email/report_submitted.html b/opentech/apply/activity/templates/messages/email/report_submitted.html
new file mode 100644
index 0000000000000000000000000000000000000000..dc9869a83f952e92692ca2154a21535ae98265e5
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/report_submitted.html
@@ -0,0 +1,10 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+An {{ ORG_SHORT_NAME }} staff member has submitted a report for {{ source.title }} for period {{ report.end_date }} to {{ report.end_date }}.
+
+You can review the report here: {{ request.scheme }}://{{ request.get_host }}{{ report.get_absolute_url }}
+
+Project: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py
index 9b963a58f1b778eafe431a0da02875d55bd3a9d1..acfcb49296d9ffbfc4ddad2b5900102a53f5d551 100644
--- a/opentech/apply/dashboard/views.py
+++ b/opentech/apply/dashboard/views.py
@@ -342,7 +342,7 @@ class ApplicantDashboardView(MultiTableMixin, TemplateView):
         return context
 
     def get_active_project_data(self, user):
-        return Project.objects.filter(user=user).in_progress().for_table()
+        return Project.objects.filter(user=user).active().for_table()
 
     def get_active_submissions(self, user):
         active_subs = ApplicationSubmission.objects.filter(
diff --git a/opentech/apply/determinations/forms.py b/opentech/apply/determinations/forms.py
index 12a3417a0e07dd7b1252d175fffd0f0ade2ed77f..4a94b7b24c761ceb0943f8fd1ebf0852355f2a05 100644
--- a/opentech/apply/determinations/forms.py
+++ b/opentech/apply/determinations/forms.py
@@ -2,7 +2,7 @@ from django import forms
 from django.contrib.auth import get_user_model
 from django.core.exceptions import NON_FIELD_ERRORS
 
-from opentech.apply.utils.options import RICH_TEXT_WIDGET
+from opentech.apply.utils.fields import RichTextField
 from opentech.apply.funds.models import ApplicationSubmission
 
 from .models import (
@@ -16,18 +16,6 @@ from .utils import determination_actions
 User = get_user_model()
 
 
-class RichTextField(forms.CharField):
-    widget = RICH_TEXT_WIDGET
-
-    def __init__(self, *args, required=False, **kwargs):
-        kwargs.update(required=required)
-        super().__init__(*args, **kwargs)
-
-
-class RequiredRichTextField(forms.CharField):
-    widget = RICH_TEXT_WIDGET
-
-
 class BaseDeterminationForm:
     def __init__(self, *args, user, initial, action, **kwargs):
         if 'site' in kwargs:
diff --git a/opentech/apply/funds/tables.py b/opentech/apply/funds/tables.py
index 90821692c36308900cd77695b5ccfbe873dc05b0..ff5ff55a2fa922443613fa9913a31f9d873503d4 100644
--- a/opentech/apply/funds/tables.py
+++ b/opentech/apply/funds/tables.py
@@ -288,7 +288,7 @@ class RoundsTable(tables.Table):
 
     class Meta:
         fields = ('title', 'fund', 'lead', 'start_date', 'end_date', 'progress')
-        attrs = {'class': 'all-rounds-table'}
+        attrs = {'class': 'responsive-table'}
 
     def render_lead(self, value):
         return format_html('<span>{}</span>', value)
diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
index de0201ac2a9efe9ea276af1d57886261656c0462..051a657f3bde149a3b3faa40e0db67e3ab3d22da 100644
--- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
+++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html
@@ -52,7 +52,7 @@
 
 </div>
 
-<div class="filters">
+<div class="filters {% if filter_classes %}{{filter_classes}}{% endif %}">
     <div class="filters__header">
         <button class="filters__button js-clear-filters">Clear</button>
         <div>Filter by</div>
diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py
index 20fd24800615cf1fa837906d852bf1b63e402823..7ded87fb773e9d7d678dac3f91c19cd773177288 100644
--- a/opentech/apply/funds/tests/factories/models.py
+++ b/opentech/apply/funds/tests/factories/models.py
@@ -72,22 +72,12 @@ class AbstractApplicationFactory(wagtail_factories.PageFactory):
         workflow_stages = 1
 
     title = factory.Faker('sentence')
+    parent = factory.SubFactory(ApplyHomePageFactory)
 
     # Will need to update how the stages are identified as Fund Page changes
     workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages))
     approval_form = factory.SubFactory('opentech.apply.projects.tests.factories.ProjectApprovalFormFactory')
 
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        # THIS MUST BE THE FIRST POST GENERATION METHOD OR THE OBJECT WILL BE UNSAVED
-        if create:
-            if extracted_parent and parent_kwargs:
-                raise ValueError('Cant pass a parent instance and attributes')
-
-            parent = extracted_parent or ApplyHomePageFactory(**parent_kwargs)
-
-            parent.add_child(instance=self)
-
     @factory.post_generation
     def forms(self, create, extracted, **kwargs):
         if create:
@@ -152,12 +142,7 @@ class RoundFactory(wagtail_factories.PageFactory):
     start_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * n + 1))
     end_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * (n + 1)))
     lead = factory.SubFactory(StaffFactory)
-
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        if create:
-            parent = extracted_parent or FundTypeFactory(**parent_kwargs)
-            parent.add_child(instance=self)
+    parent = factory.SubFactory(FundTypeFactory)
 
     @factory.post_generation
     def forms(self, create, extracted, **kwargs):
diff --git a/opentech/apply/home/factories.py b/opentech/apply/home/factories.py
index 89628412d6bbb099533b39452a8c0ce1a54a037b..bd18111c0bcb127625d3f6ca1ba14dedf5741b8d 100644
--- a/opentech/apply/home/factories.py
+++ b/opentech/apply/home/factories.py
@@ -1,10 +1,17 @@
-import factory
 import wagtail_factories
 
 from .models import ApplyHomePage
 
 
+class ApplySiteFactory(wagtail_factories.SiteFactory):
+    class Meta:
+        django_get_or_create = ('hostname',)
+
+
 class ApplyHomePageFactory(wagtail_factories.PageFactory):
+    title = "Apply Home"
+    slug = 'apply'
+
     class Meta:
         model = ApplyHomePage
 
@@ -15,14 +22,3 @@ class ApplyHomePageFactory(wagtail_factories.PageFactory):
             return model_class.objects.get(slug=kwargs['slug'])
         except model_class.DoesNotExist:
             return super()._create(model_class, *args, **kwargs)
-
-    @factory.post_generation
-    def parent(self, create, extracted_parent, **parent_kwargs):
-        if create and not self.get_parent():
-            root = ApplyHomePage.get_first_root_node()
-            root.add_child(instance=self)
-
-    @factory.post_generation
-    def site(self, create, extracted_site, **site_kwargs):
-        if create:
-            wagtail_factories.SiteFactory(root_page=self, is_default_site=True)
diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py
index 260cc0ec0cc8909f4af22a1f6db53b8ad4556cb2..8f793c80c529814f32a28177548c422543122ed4 100644
--- a/opentech/apply/projects/filters.py
+++ b/opentech/apply/projects/filters.py
@@ -12,7 +12,8 @@ from .models import (
     PROJECT_STATUS_CHOICES,
     REQUEST_STATUS_CHOICES,
     PaymentRequest,
-    Project
+    Project,
+    Report,
 )
 
 User = get_user_model()
@@ -41,3 +42,40 @@ class ProjectListFilter(filters.FilterSet):
     class Meta:
         fields = ['status', 'lead', 'fund']
         model = Project
+
+
+class DateRangeInputWidget(filters.widgets.SuffixedMultiWidget):
+    template_name = 'application_projects/filters/widgets/date_range_input_widget.html'
+    suffixes = ['after', 'before']
+
+    def __init__(self, attrs=None):
+        widgets = (forms.DateInput, forms.DateInput)
+        super().__init__(widgets, attrs)
+
+    def decompress(self, value):
+        if value:
+            return [value.start, value.stop]
+        return [None, None]
+
+
+class ReportListFilter(filters.FilterSet):
+    reporting_period = filters.DateFromToRangeFilter(
+        label="Reporting Period",
+        method="filter_reporting_period",
+        widget=DateRangeInputWidget,
+    )
+    submitted = filters.DateFromToRangeFilter(widget=DateRangeInputWidget)
+
+    class Meta:
+        model = Report
+        fields = ['submitted']
+
+    def filter_reporting_period(self, queryset, name, value):
+        after, before = value.start, value.stop
+        q = {}
+        if after:
+            q['start__gte'] = after
+        if before:
+            q['end_date__lte'] = before
+
+        return queryset.filter(**q)
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index 5dc566f88d8a51ea809e0e9e25289dc10699f2b1..eeeb245ba3c46e3b1e0d94de8e9902df6601fc66 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -2,14 +2,18 @@ import functools
 
 from django import forms
 from django.contrib.auth import get_user_model
+from django.core.exceptions import ValidationError
 from django.core.files.base import ContentFile
 from django.db import transaction
 from django.db.models import Q
+from django.utils import timezone
+from django.utils.translation import gettext as _
 
 from addressfield.fields import AddressField
 from opentech.apply.funds.models import ApplicationSubmission
 from opentech.apply.stream_forms.fields import MultiFileField
 from opentech.apply.users.groups import STAFF_GROUP_NAME
+from opentech.apply.utils.fields import RichTextField
 
 from .models import (
     CHANGES_REQUESTED,
@@ -24,7 +28,11 @@ from .models import (
     PacketFile,
     PaymentReceipt,
     PaymentRequest,
-    Project
+    Project,
+    Report,
+    ReportConfig,
+    ReportVersion,
+    ReportPrivateFiles,
 )
 
 
@@ -369,3 +377,138 @@ class UpdateProjectLeadForm(forms.ModelForm):
         lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id)
                                                   .filter(qwargs)
                                                   .distinct())
+
+
+class ReportEditForm(forms.ModelForm):
+    public_content = RichTextField(
+        help_text="This section of the report will be shared with the broader community."
+    )
+    private_content = RichTextField(
+        help_text="This section of the report will be shared with staff only."
+    )
+    file_list = forms.ModelMultipleChoiceField(
+        widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}),
+        queryset=ReportPrivateFiles.objects.all(),
+        required=False,
+        label='Files'
+    )
+    files = MultiFileField(required=False, label='')
+
+    class Meta:
+        model = Report
+        fields = (
+            'public_content',
+            'private_content',
+            'file_list',
+            'files',
+        )
+
+    def __init__(self, *args, user=None, initial={}, **kwargs):
+        self.report_files = initial.pop(
+            'file_list',
+            ReportPrivateFiles.objects.none(),
+        )
+        super().__init__(*args, initial=initial, **kwargs)
+        self.fields['file_list'].queryset = self.report_files
+        self.user = user
+
+    def clean(self):
+        cleaned_data = super().clean()
+        public = cleaned_data['public_content']
+        private = cleaned_data['private_content']
+        if not private and not public:
+            missing_content = 'Must include either public or private content when submitting a report.'
+            self.add_error('public_content', missing_content)
+            self.add_error('private_content', missing_content)
+        return cleaned_data
+
+    @transaction.atomic
+    def save(self, commit=True):
+        is_draft = 'save' in self.data
+
+        version = ReportVersion.objects.create(
+            report=self.instance,
+            public_content=self.cleaned_data['public_content'],
+            private_content=self.cleaned_data['private_content'],
+            submitted=timezone.now(),
+            draft=is_draft,
+            author=self.user,
+        )
+
+        if is_draft:
+            self.instance.draft = version
+        else:
+            # If this is the first submission of the report we track that as the
+            # submitted date of the report
+            if not self.instance.submitted:
+                self.instance.submitted = version.submitted
+            self.instance.current = version
+            self.instance.draft = None
+
+        instance = super().save(commit)
+
+        removed_files = self.cleaned_data['file_list']
+        ReportPrivateFiles.objects.bulk_create(
+            ReportPrivateFiles(report=version, document=file.document)
+            for file in self.report_files
+            if file not in removed_files
+        )
+
+        added_files = self.cleaned_data['files']
+        if added_files:
+            ReportPrivateFiles.objects.bulk_create(
+                ReportPrivateFiles(report=version, document=file)
+                for file in added_files
+            )
+
+        return instance
+
+
+class ReportFrequencyForm(forms.ModelForm):
+    start = forms.DateField(label='Next report date')
+
+    class Meta:
+        model = ReportConfig
+        fields = ('occurrence', 'frequency', 'start')
+        labels = {
+            'occurrence': 'No.',
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        today = timezone.now().date()
+        last_report = self.instance.last_report()
+
+        self.fields['start'].widget.attrs.update({
+            'min': max(
+                last_report.end_date if last_report else today,
+                today,
+            ),
+            'max': self.instance.project.end_date,
+        })
+
+    def clean_start(self):
+        start_date = self.cleaned_data['start']
+        last_report = self.instance.last_report()
+        if last_report and start_date <= last_report.end_date:
+            raise ValidationError(
+                _("Cannot start a schedule before the current reporting period"),
+                code="bad_start"
+            )
+
+        if start_date < timezone.now().date():
+            raise ValidationError(
+                _("Cannot start a schedule in the past"),
+                code="bad_start"
+            )
+
+        if start_date > self.instance.project.end_date:
+            raise ValidationError(
+                _("Cannot start a schedule beyond the end date"),
+                code="bad_start"
+            )
+        return start_date
+
+    def save(self, *args, **kwargs):
+        self.instance.schedule_start = self.cleaned_data['start']
+        return super().save(*args, **kwargs)
diff --git a/opentech/apply/projects/management/commands/notify_report_due.py b/opentech/apply/projects/management/commands/notify_report_due.py
new file mode 100644
index 0000000000000000000000000000000000000000..7f0c290cf12f0ca31ba4817b99239ba525bd04e0
--- /dev/null
+++ b/opentech/apply/projects/management/commands/notify_report_due.py
@@ -0,0 +1,55 @@
+from dateutil.relativedelta import relativedelta
+
+from django.conf import settings
+from django.contrib.messages.storage.fallback import FallbackStorage
+from django.core.management.base import BaseCommand
+from django.http import HttpRequest
+from django.utils import timezone
+from django.urls import set_urlconf
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.home.models import ApplyHomePage
+from opentech.apply.projects.models import Project
+
+
+class Command(BaseCommand):
+    help = 'Notify users that they have a report due soon'
+
+    def add_arguments(self, parser):
+        parser.add_argument('days_before', type=int)
+
+    def handle(self, *args, **options):
+        site = ApplyHomePage.objects.first().get_site()
+        set_urlconf('opentech.apply.urls')
+
+        # Mock a HTTPRequest in order to pass the site settings into the
+        # templates
+        request = HttpRequest()
+        request.META['SERVER_NAME'] = site.hostname
+        request.META['SERVER_PORT'] = site.port
+        request.META[settings.SECURE_PROXY_SSL_HEADER] = 'https'
+        request.session = {}
+        request._messages = FallbackStorage(request)
+
+        today = timezone.now().date()
+        due_date = today + relativedelta(days=options['days_before'])
+        for project in Project.objects.in_progress():
+            next_report = project.report_config.current_due_report()
+            due_soon = next_report.end_date == due_date
+            not_notified_today = (
+                not next_report.notified or
+                next_report.notified.date() != today
+            )
+            if due_soon and not_notified_today:
+                messenger(
+                    MESSAGES.REPORT_NOTIFY,
+                    request=request,
+                    user=None,
+                    source=project,
+                    related=next_report,
+                )
+                # Notify about the due report
+                next_report.notified = timezone.now()
+                next_report.save()
+                self.stdout.write(
+                    self.style.SUCCESS(f'Notified project: {project.id}')
+                )
diff --git a/opentech/apply/projects/migrations/0025_add_report_models.py b/opentech/apply/projects/migrations/0025_add_report_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..de6630d4f6f93d448385fd369da2ba392633f44a
--- /dev/null
+++ b/opentech/apply/projects/migrations/0025_add_report_models.py
@@ -0,0 +1,64 @@
+# Generated by Django 2.1.11 on 2019-10-28 14:15
+
+import django.core.files.storage
+from django.db import migrations, models
+import django.db.models.deletion
+import opentech.apply.projects.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0024_allow_no_comments_on_pr'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Report',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('public', models.BooleanField(default=True)),
+                ('end_date', models.DateField()),
+                ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='application_projects.Project')),
+            ],
+            options={
+                'ordering': ('-end_date',),
+            },
+        ),
+        migrations.CreateModel(
+            name='ReportConfig',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('schedule_start', models.DateField(null=True)),
+                ('occurrence', models.PositiveSmallIntegerField(default=1)),
+                ('frequency', models.CharField(choices=[('week', 'Weeks'), ('month', 'Months')], default='month', max_length=5)),
+                ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='report_config', to='application_projects.Project')),
+            ],
+        ),
+        migrations.CreateModel(
+            name='ReportPrivateFiles',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('document', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.report_path)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='ReportVersion',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('submitted', models.DateTimeField()),
+                ('content', models.TextField()),
+                ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='application_projects.Report')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='contract',
+            name='approved_at',
+            field=models.DateTimeField(null=True),
+        ),
+        migrations.AddField(
+            model_name='reportprivatefiles',
+            name='report',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='application_projects.ReportVersion'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0026_data_contract_approved_date.py b/opentech/apply/projects/migrations/0026_data_contract_approved_date.py
new file mode 100644
index 0000000000000000000000000000000000000000..92ba2db6c134131359e3551ac9a5eec4b251152a
--- /dev/null
+++ b/opentech/apply/projects/migrations/0026_data_contract_approved_date.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.1.11 on 2019-10-29 03:54
+
+from django.db import migrations
+from django.db.models import F
+
+
+def copy_submitted_date(apps, schema_editor):
+    Contract = apps.get_model('application_projects', 'Contract')
+    Contract.objects.all().update(approved_at=F('created_at'))
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0025_add_report_models'),
+    ]
+
+    operations = [
+        migrations.RunPython(copy_submitted_date),
+    ]
diff --git a/opentech/apply/projects/migrations/0027_report_current.py b/opentech/apply/projects/migrations/0027_report_current.py
new file mode 100644
index 0000000000000000000000000000000000000000..5337aa8ba960084866ad169ed9acc0625ea8061a
--- /dev/null
+++ b/opentech/apply/projects/migrations/0027_report_current.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.1.11 on 2019-10-30 14:34
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0026_data_contract_approved_date'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='current',
+            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='live_for_report', to='application_projects.ReportVersion'),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0028_report_draft.py b/opentech/apply/projects/migrations/0028_report_draft.py
new file mode 100644
index 0000000000000000000000000000000000000000..8ff7c23512b5a5cdc28d6e4eabd0e2c75d3cc210
--- /dev/null
+++ b/opentech/apply/projects/migrations/0028_report_draft.py
@@ -0,0 +1,32 @@
+# Generated by Django 2.1.11 on 2019-10-31 02:30
+
+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', '0027_report_current'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='draft',
+            field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='draft_for_report', to='application_projects.ReportVersion'),
+        ),
+        migrations.AddField(
+            model_name='reportversion',
+            name='draft',
+            field=models.BooleanField(default=False),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='reportversion',
+            name='author',
+            field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0029_report_submitted.py b/opentech/apply/projects/migrations/0029_report_submitted.py
new file mode 100644
index 0000000000000000000000000000000000000000..635f0b796fed614a7adc5a0b34583c445df72221
--- /dev/null
+++ b/opentech/apply/projects/migrations/0029_report_submitted.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-10-31 09:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0028_report_draft'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='submitted',
+            field=models.DateTimeField(null=True),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0030_report_skipped.py b/opentech/apply/projects/migrations/0030_report_skipped.py
new file mode 100644
index 0000000000000000000000000000000000000000..103f0df3f57e433fe568ed97770ba6c32085f531
--- /dev/null
+++ b/opentech/apply/projects/migrations/0030_report_skipped.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-10-31 14:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0029_report_submitted'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='skipped',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0031_add_public_private_content.py b/opentech/apply/projects/migrations/0031_add_public_private_content.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4af728a8736d4504e83693a75dbd1ecfc99c172
--- /dev/null
+++ b/opentech/apply/projects/migrations/0031_add_public_private_content.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.1.11 on 2019-10-31 22:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0030_report_skipped'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='reportversion',
+            old_name='content',
+            new_name='public_content',
+        ),
+        migrations.RemoveField(
+            model_name='report',
+            name='public',
+        ),
+        migrations.AddField(
+            model_name='reportversion',
+            name='private_content',
+            field=models.TextField(default=''),
+            preserve_default=False,
+        ),
+    ]
diff --git a/opentech/apply/projects/migrations/0032_report_notified.py b/opentech/apply/projects/migrations/0032_report_notified.py
new file mode 100644
index 0000000000000000000000000000000000000000..ec7ae7bf0c846eb290df9950923434cc01a237bc
--- /dev/null
+++ b/opentech/apply/projects/migrations/0032_report_notified.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.1.11 on 2019-11-05 15:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('application_projects', '0031_add_public_private_content'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='report',
+            name='notified',
+            field=models.DateTimeField(null=True),
+        ),
+    ]
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index 7e2739bd15dbb0f3ac8783c77f3f7780c7278cab..87993c947a3acc4199eb10db0405248498040bc5 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -1,22 +1,36 @@
 import collections
+import datetime
 import decimal
 import json
 import logging
 import os
 
-
+from dateutil.relativedelta import relativedelta
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.humanize.templatetags.humanize import ordinal
 from django.contrib.postgres.fields import JSONField
 from django.core.exceptions import ValidationError
 from django.core.validators import MinValueValidator
 from django.db import models
-from django.db.models import F, Max, Sum, Value as V
-from django.db.models.functions import Coalesce
+from django.db.models import (
+    Case,
+    F,
+    ExpressionWrapper,
+    Max,
+    OuterRef,
+    Q,
+    Subquery,
+    Sum,
+    Value as V,
+    When,
+)
+from django.db.models.functions import Cast, Coalesce
 from django.db.models.signals import post_delete
 from django.dispatch.dispatcher import receiver
 from django.urls import reverse
 from django.utils import timezone
+from django.utils.functional import cached_property
 from django.utils.translation import ugettext as _
 from wagtail.contrib.settings.models import BaseSetting, register_setting
 from wagtail.admin.edit_handlers import (
@@ -53,6 +67,10 @@ def receipt_path(instance, filename):
     return f'projects/{instance.payment_request.project_id}/payment_receipts/{filename}'
 
 
+def report_path(instance, filename):
+    return f'reports/{instance.report.report_id}/version/{instance.report_id}/{filename}'
+
+
 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")
@@ -79,6 +97,7 @@ class Contract(models.Model):
 
     is_signed = models.BooleanField("Signed?", default=False)
     created_at = models.DateTimeField(auto_now_add=True)
+    approved_at = models.DateTimeField(null=True)
 
     objects = ContractQuerySet.as_manager()
 
@@ -266,9 +285,16 @@ PROJECT_STATUS_CHOICES = [
 
 
 class ProjectQuerySet(models.QuerySet):
-    def in_progress(self):
+    def active(self):
+        "Projects that are not finished"
         return self.exclude(status=COMPLETE)
 
+    def in_progress(self):
+        "Projects that users need to interact with, submitting reports or payment request"
+        return self.filter(
+            status__in=(IN_PROGRESS, CLOSING,)
+        )
+
     def complete(self):
         return self.filter(status=COMPLETE)
 
@@ -294,8 +320,26 @@ class ProjectQuerySet(models.QuerySet):
             last_payment_request=Max('payment_requests__requested_at'),
         )
 
+    def with_start_date(self):
+        return self.annotate(
+            start=Cast(
+                Subquery(
+                    Contract.objects.filter(
+                        project=OuterRef('pk'),
+                    ).approved().order_by(
+                        'approved_at'
+                    ).values('approved_at')[:1]
+                ),
+                models.DateField(),
+            )
+        )
+
     def for_table(self):
-        return self.with_amount_paid().with_last_payment()
+        return self.with_amount_paid().with_last_payment().select_related(
+            'report_config',
+            'submission__page',
+            'lead',
+        )
 
 
 class Project(BaseStreamForm, AccessFormData, models.Model):
@@ -384,6 +428,24 @@ class Project(BaseStreamForm, AccessFormData, models.Model):
             value=submission.form_data.get('value', 0),
         )
 
+    @property
+    def start_date(self):
+        # Assume project starts when OTF are happy with the first signed contract
+        first_approved_contract = self.contracts.approved().order_by('approved_at').first()
+        if not first_approved_contract:
+            return None
+
+        return first_approved_contract.approved_at.date()
+
+    @property
+    def end_date(self):
+        # Aiming for the proposed end date as the last day of the project
+        # If still ongoing assume today is the end
+        return max(
+            self.proposed_end.date(),
+            timezone.now().date(),
+        )
+
     def paid_value(self):
         return self.payment_requests.paid_value()
 
@@ -527,3 +589,238 @@ class ProjectApprovalForm(BaseStreamForm, models.Model):
 
     def __str__(self):
         return self.name
+
+
+class ReportConfig(models.Model):
+    """Persists configuration about the reporting schedule etc"""
+
+    WEEK = "week"
+    MONTH = "month"
+    FREQUENCY_CHOICES = [
+        (WEEK, "Weeks"),
+        (MONTH, "Months"),
+    ]
+
+    project = models.OneToOneField("Project", on_delete=models.CASCADE, related_name="report_config")
+    schedule_start = models.DateField(null=True)
+    occurrence = models.PositiveSmallIntegerField(default=1)
+    frequency = models.CharField(choices=FREQUENCY_CHOICES, default=MONTH, max_length=5)
+
+    def get_frequency_display(self):
+        next_report = self.current_due_report()
+
+        if self.frequency == self.MONTH:
+            if self.schedule_start and self.schedule_start.day == 31:
+                day_of_month = 'last day'
+            else:
+                day_of_month = ordinal(next_report.end_date.day)
+            if self.occurrence == 1:
+                return f"Monthly on the { day_of_month } of the month"
+            return f"Every { self.occurrence } months on the { day_of_month } of the month"
+
+        weekday = next_report.end_date.strftime('%A')
+
+        if self.occurrence == 1:
+            return f"Every week on { weekday }"
+        return f"Every {self.occurrence} weeks on { weekday }"
+
+    def is_up_to_date(self):
+        return len(self.project.reports.to_do()) == 0
+
+    def outstanding_reports(self):
+        return len(self.project.reports.to_do())
+
+    def has_very_late_reports(self):
+        return self.project.reports.any_very_late()
+
+    def past_due_reports(self):
+        return self.project.reports.to_do()
+
+    def last_report(self):
+        today = timezone.now().date()
+        return self.project.reports.filter(
+            Q(end_date__lt=today) | Q(current__isnull=False)
+        ).first()
+
+    def current_due_report(self):
+        # Project not started - no reporting required
+        if not self.project.start_date:
+            return None
+
+        today = timezone.now().date()
+
+        last_report = self.last_report()
+
+        schedule_date = self.schedule_start or self.project.start_date
+
+        if last_report:
+            if last_report.end_date < schedule_date:
+                # reporting schedule changed schedule_start is now the next report date
+                next_due_date = schedule_date
+            else:
+                # we've had a report since the schedule date so base next deadline from the report
+                next_due_date = self.next_date(last_report.end_date)
+        else:
+            # first report required
+            if self.schedule_start and self.schedule_start >= today:
+                # Schedule changed since project inception
+                next_due_date = self.schedule_start
+            else:
+                # schedule_start is the first day the project so the "last" period
+                # ended one day before that. If date is in past we required report now
+                next_due_date = max(
+                    self.next_date(schedule_date - relativedelta(days=1)),
+                    today,
+                )
+
+        report, _ = self.project.reports.update_or_create(
+            project=self.project,
+            end_date__gte=today,
+            current__isnull=True,
+            defaults={'end_date': next_due_date}
+        )
+        return report
+
+    def next_date(self, last_date):
+        delta_frequency = self.frequency + 's'
+        delta = relativedelta(**{delta_frequency: self.occurrence})
+        next_date = last_date + delta
+        return next_date
+
+
+class ReportQueryset(models.QuerySet):
+    def done(self):
+        return self.filter(
+            Q(current__isnull=False) | Q(skipped=True),
+        )
+
+    def to_do(self):
+        today = timezone.now().date()
+        return self.filter(
+            current__isnull=True,
+            skipped=False,
+            end_date__lt=today,
+        ).order_by('end_date')
+
+    def any_very_late(self):
+        two_weeks_ago = timezone.now().date() - relativedelta(weeks=2)
+        return self.to_do().filter(end_date__lte=two_weeks_ago)
+
+    def submitted(self):
+        return self.filter(current__isnull=False)
+
+    def for_table(self):
+        return self.annotate(
+            last_end_date=Subquery(
+                Report.objects.filter(
+                    project=OuterRef('project_id'),
+                    end_date__lt=OuterRef('end_date')
+                ).values('end_date')[:1]
+            ),
+            project_start_date=Subquery(
+                Project.objects.filter(
+                    pk=OuterRef('project_id'),
+                ).with_start_date().values('start')[:1]
+            ),
+            start=Case(
+                When(
+                    last_end_date__isnull=False,
+                    # Expression Wrapper doesn't cast the calculated object
+                    # Use cast to get an actual date object
+                    then=Cast(
+                        ExpressionWrapper(
+                            F('last_end_date') + datetime.timedelta(days=1),
+                            output_field=models.DateTimeField(),
+                        ),
+                        models.DateField(),
+                    ),
+                ),
+                default=F('project_start_date'),
+                output_field=models.DateField(),
+            )
+        )
+
+
+class Report(models.Model):
+    skipped = models.BooleanField(default=False)
+    end_date = models.DateField()
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports")
+    submitted = models.DateTimeField(null=True)
+    notified = models.DateTimeField(null=True)
+    current = models.OneToOneField(
+        "ReportVersion",
+        on_delete=models.CASCADE,
+        related_name='live_for_report',
+        null=True,
+    )
+    draft = models.OneToOneField(
+        "ReportVersion",
+        on_delete=models.CASCADE,
+        related_name='draft_for_report',
+        null=True,
+    )
+
+    objects = ReportQueryset.as_manager()
+
+    class Meta:
+        ordering = ('-end_date',)
+
+    def get_absolute_url(self):
+        return reverse('apply:projects:reports:detail', kwargs={'pk': self.pk})
+
+    @property
+    def past_due(self):
+        return timezone.now().date() > self.end_date
+
+    @property
+    def is_very_late(self):
+        two_weeks_ago = timezone.now().date() - relativedelta(weeks=2)
+        two_weeks_late = self.end_date < two_weeks_ago
+        not_submitted = not self.current
+        return not_submitted and two_weeks_late
+
+    @property
+    def can_submit(self):
+        return self.start_date <= timezone.now().date()
+
+    @property
+    def submitted_date(self):
+        if self.submitted:
+            return self.submitted.date()
+
+    @cached_property
+    def start_date(self):
+        last_report = self.project.reports.filter(end_date__lt=self.end_date).first()
+        if last_report:
+            return last_report.end_date + relativedelta(days=1)
+
+        return self.project.start_date
+
+
+class ReportVersion(models.Model):
+    report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions")
+    submitted = models.DateTimeField()
+    public_content = models.TextField()
+    private_content = models.TextField()
+    draft = models.BooleanField()
+    author = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        on_delete=models.SET_NULL,
+        related_name="reports",
+        null=True,
+    )
+
+
+class ReportPrivateFiles(models.Model):
+    report = models.ForeignKey("ReportVersion", on_delete=models.CASCADE, related_name="files")
+    document = models.FileField(upload_to=report_path, storage=PrivateStorage())
+
+    @property
+    def filename(self):
+        return os.path.basename(self.document.name)
+
+    def __str__(self):
+        return self.filename
+
+    def get_absolute_url(self):
+        return reverse('apply:projects:reports:document', kwargs={'pk': self.report.report_id, 'file_pk': self.pk})
diff --git a/opentech/apply/projects/tables.py b/opentech/apply/projects/tables.py
index 1aaa9d67b08e956983f158be9d74fb0923e47e8a..dc07414b3c84cd08f2eedd791f63a043c9de6834 100644
--- a/opentech/apply/projects/tables.py
+++ b/opentech/apply/projects/tables.py
@@ -3,8 +3,9 @@ import textwrap
 import django_tables2 as tables
 from django.db.models import F, Sum
 from django.contrib.humanize.templatetags.humanize import intcomma
+from django.utils.safestring import mark_safe
 
-from .models import PaymentRequest, Project
+from .models import PaymentRequest, Project, Report
 
 
 class BasePaymentRequestsTable(tables.Table):
@@ -71,6 +72,7 @@ class BaseProjectsTable(tables.Table):
     )
     status = tables.Column(verbose_name='Status', accessor='get_status_display', order_by=('status',))
     fund = tables.Column(verbose_name='Fund', accessor='submission.page')
+    reporting = tables.Column(verbose_name='Reporting', accessor='pk')
     last_payment_request = tables.DateColumn()
     end_date = tables.DateColumn(verbose_name='End Date', accessor='proposed_end')
     fund_allocation = tables.Column(verbose_name='Fund Allocation', accessor='value')
@@ -78,6 +80,21 @@ class BaseProjectsTable(tables.Table):
     def render_fund_allocation(self, record):
         return f'${intcomma(record.amount_paid)} / ${intcomma(record.value)}'
 
+    def render_reporting(self, record):
+        if not hasattr(record, 'report_config'):
+            return '-'
+
+        if record.report_config.is_up_to_date():
+            return 'Up to date'
+
+        if record.report_config.has_very_late_reports():
+            display = '<svg class="icon"><use xlink:href="#exclamation-point"></use></svg>'
+        else:
+            display = ''
+
+        display += f'{ record.report_config.outstanding_reports() } outstanding'
+        return mark_safe(display)
+
 
 class ProjectsDashboardTable(BaseProjectsTable):
     class Meta:
@@ -85,12 +102,14 @@ class ProjectsDashboardTable(BaseProjectsTable):
             'title',
             'status',
             'fund',
+            'reporting',
             'last_payment_request',
             'end_date',
             'fund_allocation',
         ]
         model = Project
         orderable = False
+        attrs = {'class': 'projects-table'}
 
 
 class ProjectsListTable(BaseProjectsTable):
@@ -100,6 +119,7 @@ class ProjectsListTable(BaseProjectsTable):
             'status',
             'lead',
             'fund',
+            'reporting',
             'last_payment_request',
             'end_date',
             'fund_allocation',
@@ -107,6 +127,7 @@ class ProjectsListTable(BaseProjectsTable):
         model = Project
         orderable = True
         order_by = ('-end_date',)
+        attrs = {'class': 'projects-table'}
 
     def order_fund_allocation(self, qs, is_descending):
         direction = '-' if is_descending else ''
@@ -119,3 +140,30 @@ class ProjectsListTable(BaseProjectsTable):
 
     def order_end_date(self, qs, desc):
         return qs.by_end_date(desc), True
+
+
+class ReportListTable(tables.Table):
+    project = tables.LinkColumn(
+        'funds:projects:reports:detail',
+        text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."),
+        args=[tables.utils.A('pk')],
+    )
+    report_period = tables.Column(accessor='pk')
+    submitted = tables.DateColumn()
+    lead = tables.Column(accessor='project.lead')
+
+    class Meta:
+        fields = [
+            'project',
+            'submitted',
+        ]
+        sequence = [
+            'project',
+            'report_period',
+            '...'
+        ]
+        model = Report
+        attrs = {'class': 'responsive-table'}
+
+    def render_report_period(self, record):
+        return f"{record.start} to {record.end_date}"
diff --git a/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html
new file mode 100644
index 0000000000000000000000000000000000000000..d498285f5c11f080e261a510ff8e8d6ce2453eb8
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html
@@ -0,0 +1,4 @@
+{% include widget.subwidgets.0.template_name %}
+<span>to:</span>
+{% include widget.subwidgets.1.template_name %}
+
diff --git a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
index 940cd498aae764b52bcc889bcca71295a47cda28..565b75271eea8439a6a84b3705daba606bd32708 100644
--- a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
+++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
@@ -1,81 +1,81 @@
 {% load payment_request_tools humanize %}
 
-<div id="payment-requests" class="payment-block">
-    <div class="payment-block__header">
-        <p class="payment-block__title">Payment Requests</p>
-        <a class="payment-block__button button button--primary"
+<div id="payment-requests" class="data-block">
+    <div class="data-block__header">
+        <p class="data-block__title">Payment Requests</p>
+        <a class="data-block__button button button--primary"
            href="{% url "apply:projects:request" pk=object.pk %}">
             Add Request
         </a>
     </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-date">From</th>
-                <th class="payment-block__table-date">To</th>
-                <th class="payment-block__table-update"></th>
-            </tr>
-        </thead>
-        <tbody>
-            {% for payment_request in object.payment_requests.not_rejected %}
-            <tr>
-                <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td>
-                <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
-                <td><span class="payment-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td>
-                <td><span class="payment-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td>
-                <td>
-                    <a href="{{ payment_request.get_absolute_url }}">View</a>
-                    {% can_edit payment_request user as user_can_edit_request %}
-                    {% if user_can_edit_request %}
-                    <a href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}">
-                        Edit
-                    </a>
-                    {% endif %}
-
-                    {% can_delete payment_request user as user_can_delete_request %}
-                    {% if user_can_delete_request %}
-                    <a href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}">
-                        Delete
-                    </a>
-                    {% endif %}
-                </td>
-            </tr>
-            {% empty %}
-            <tr>
-                <td colspan="5">No active Payment Requests.</td>
-            </tr>
-            {% endfor %}
-        </tbody>
-    </table>
-
-    {% if object.payment_requests.rejected %}
-        <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">
+    <div class="data-block__body">
+        <table class="data-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-view"></th>
+                    <th class="data-block__table-amount">Amount</th>
+                    <th class="data-block__table-status">Status</th>
+                    <th class="data-block__table-date">From</th>
+                    <th class="data-block__table-date">To</th>
+                    <th class="data-block__table-update"></th>
                 </tr>
             </thead>
             <tbody>
-                {% for payment_request in object.payment_requests.rejected %}
+                {% for payment_request in object.payment_requests.not_rejected %}
+                <tr>
+                    <td><span class="data-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td>
+                    <td><span class="data-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
+                    <td><span class="data-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td>
+                    <td><span class="data-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td>
+                    <td>
+                        <a class="data-block__action-link" href="{{ payment_request.get_absolute_url }}">View</a>
+                        {% can_edit payment_request user as user_can_edit_request %}
+                        {% if user_can_edit_request %}
+                        <a class="data-block__action-link" href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}">
+                            Edit
+                        </a>
+                        {% endif %}
+
+                        {% can_delete payment_request user as user_can_delete_request %}
+                        {% if user_can_delete_request %}
+                        <a class="data-block__action-link" href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}">
+                            Delete
+                        </a>
+                        {% endif %}
+                    </td>
+                </tr>
+                {% empty %}
                 <tr>
-                    <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value }}</td>
-                    <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
-                    <td><a href="{{ payment_request.get_absolute_url }}">View</a></td>
+                    <td colspan="5">No active Payment Requests.</td>
                 </tr>
                 {% endfor %}
             </tbody>
         </table>
-    {% endif %}
 
+        {% if object.payment_requests.rejected %}
+            <p class="data-block__rejected">
+                <a class="data-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a>
+            </p>
+
+            <table class="data-block__table is-hidden js-payment-block-rejected-table">
+                <thead>
+                    <tr>
+                        <th class="data-block__table-amount">Amount</th>
+                        <th class="data-block__table-status">Status</th>
+                        <th class="data-block__table-view"></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for payment_request in object.payment_requests.rejected %}
+                    <tr>
+                        <td><span class="data-block__mobile-label">Amount: </span>${{ payment_request.value }}</td>
+                        <td><span class="data-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td>
+                        <td><a href="{{ payment_request.get_absolute_url }}">View</a></td>
+                    </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+        {% endif %}
+    </div>
 </div>
 
 {% for form in change_payment_request_status_forms %}
diff --git a/opentech/apply/projects/templates/application_projects/includes/report_line.html b/opentech/apply/projects/templates/application_projects/includes/report_line.html
new file mode 100644
index 0000000000000000000000000000000000000000..b1782b17e982d94167460efa970284d7b0d2a693
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/report_line.html
@@ -0,0 +1,46 @@
+<li class="data-block__list-item">
+    <div class="data-block__info">
+        {% if current %}
+            The {% if report.can_submit %}current{% else %}next{% endif %} reporting period is
+        {% else %}
+            A report is due for the period
+        {% endif %}
+            <b>{{ report.start_date }}</b> to <b>{{ report.end_date }}</b>
+        {% if report.is_very_late %}
+            <svg class="icon data-block__icon"><use xlink:href="#exclamation-point"></use></svg>
+        {% endif %}
+    </div>
+
+    <div class="data-block__links">
+        {% if report.can_submit %}
+            <a
+                class="data-block__button button button--primary"
+                href="{% url "apply:projects:reports:edit" pk=report.pk %}"
+            >
+            {% if report.draft %}Continue Editing{% else %}Add Report{% endif %}
+            </a>
+        {% endif %}
+
+        {% if request.user.is_apply_staff and report.can_submit %}
+
+            <input data-fancybox data-src="#skip-report-{{report.id}}" type="button" value="Skip" class="btn data-block__action-link"></input>
+
+            <!-- Skip report confirmation modal -->
+            <div class="modal" id="skip-report-{{report.id}}">
+                <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Skip report</h4>
+                <div class="modal__copy">
+                    <p>You're skipping the report for <b>{{report.start_date}}</b> &ndash; <b>{{report.end_date}}</b></p>
+                    <p>This will result in a gap in reporting for the project. You can undo this at any time.</p>
+                </div>
+                <form action="{% url "apply:projects:reports:skip" pk=report.pk %}" method="post">
+                    {% csrf_token %}
+                    <div class="modal__buttons">
+                        <input type="submit" value="Continue" class="button button--primary button--submit"></input>
+                        <button data-fancybox-close class="button button--submit button--white">Cancel</button>
+                    </div>
+                </form>
+            </div>
+
+        {% endif %}
+    </div>
+</li>
diff --git a/opentech/apply/projects/templates/application_projects/includes/reports.html b/opentech/apply/projects/templates/application_projects/includes/reports.html
new file mode 100644
index 0000000000000000000000000000000000000000..43e6b96f5af327adfdccabd000b8dedd3296a01d
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/reports.html
@@ -0,0 +1,106 @@
+<div class="wrapper wrapper--outer-space-large">
+    <div class="data-block">
+        <div class="data-block__header">
+            <p class="data-block__title">Reporting</p>
+        </div>
+        <div class="data-block__body">
+            <div class="data-block__card">
+                <p class="data-block__card-title">Report frequency</p>
+                <p class="data-block__card-copy">{{ object.report_config.get_frequency_display }}</p>
+                {% if request.user.is_apply_staff %}
+                    <p class="data-block__card-copy">
+                        <a data-fancybox data-src="#change-frequency" href="#" class="data-block__action-link">Change</a>
+                    </p>
+                    <!-- Change report frequency modal -->
+                    <div class="modal" id="change-frequency">
+                        {{ report_data|json_script:"reportData" }}
+                        <h4 class="modal__header-bar">Change reporting frequency</h4>
+                        <div class="form__info-box">
+                            <p>
+                                Next report will be due in
+                                <b class="js-next-report-due-slot">(please choose the next report date)</b>
+                                and the report period will be
+                                <b class="js-report-period-start"></b>
+                                to
+                                <b class="js-report-period-end">(please choose the next report date)</b>
+                                and then every
+                                <b class="js-frequency-number-slot"></b>
+                                <b class="js-frequency-period-slot"></b>
+                                after until the project end date:
+                                <span class="js-project-end-slot"></span>.
+                            </p>
+                        </div>
+                        <p>Schedule reports every:</p>
+                        {% include 'funds/includes/delegated_form_base.html' with form=update_frequency_form value='Continue' extra_classes="form--report-frequency" %}
+                    </div>
+                {% endif %}
+            </div>
+            <ul class="data-block__list">
+                {% for report in object.report_config.past_due_reports %}
+                    {% include "application_projects/includes/report_line.html" with report=report %}
+                {% endfor %}
+                {% with next_report=object.report_config.current_due_report %}
+                    {% include "application_projects/includes/report_line.html" with report=next_report current=True %}
+                {% endwith %}
+            </ul>
+        </div>
+    </div>
+</div>
+
+
+<div class="wrapper wrapper--outer-space-large">
+    <div class="data-block">
+        <div class="data-block__header">
+            <p class="data-block__title">Past reports</p>
+        </div>
+        <div class="data-block__body">
+            <table class="data-block__table js-past-reports-table">
+                <thead>
+                    <tr>
+                        <th class="data-block__table-date">Period End</th>
+                        <th class="data-block__table-date">Submitted</th>
+                        <th class="data-block__table-date">Privacy</th>
+                        <th class="data-block__table-update"></th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {% for report in object.reports.done %}
+                        <tr {% if forloop.counter > 8 %}class="is-hidden"{% endif %}>
+                            <td>
+                                <span class="data-block__mobile-label">Period End: </span>{{ report.end_date }}
+                            </td>
+                            <td>
+                                <span class="data-block__mobile-label">Submitted: </span>{{ report.submitted_date|default:"Skipped" }}
+                            </td>
+                            <td>
+                                <span class="data-block__mobile-label">Privacy: </span>{% if report.public %}Public{% else %}Private{% endif %}
+                            </td>
+                            <td class="data-block__links">
+                                <a class="data-block__action-link" href="{% url "apply:projects:reports:detail" pk=report.pk %}">View</a>
+
+                                {% if request.user.is_apply_staff %}
+                                    <a class="data-block__action-link" href="{% url "apply:projects:reports:edit" pk=report.pk %}">Edit</a>
+                                    {% if report.skipped %}
+                                        <form action="{% url "apply:projects:reports:skip" pk=report.pk %}" method="post">
+                                            {% csrf_token %}
+                                            <input type="submit" value="Unskip" class="btn data-block__action-link"></input>
+                                        </form>
+                                    {% endif %}
+                                {% endif %}
+                            </td>
+                        </tr>
+                        {% empty %}
+                        <tr>
+                            <td colspan="4">No reports submitted</td>
+                        </tr>
+                    {% endfor %}
+                </tbody>
+            </table>
+            {% if object.reports.done.count > 8 %}
+                <p class="data-block__pagination">
+                    <a class="data-block__pagination-link js-data-block-pagination" href="#">Show more</a>
+                </p>
+            {% endif %}
+        </div>
+    </div>
+</div>
diff --git a/opentech/apply/projects/templates/application_projects/overview.html b/opentech/apply/projects/templates/application_projects/overview.html
index 6adc7c31c7fa1a93580e53243aba9a11d2c182d5..e3159e8497eae29124e8a3b0b55e656e3d69955f 100644
--- a/opentech/apply/projects/templates/application_projects/overview.html
+++ b/opentech/apply/projects/templates/application_projects/overview.html
@@ -54,6 +54,19 @@
     </div>
     {% endif %}
 
+    {% if reports.table.data %}
+    <div class="wrapper wrapper--bottom-space">
+
+        {% include "funds/includes/table_filter_and_search.html" with filter=reports.filterset filter_action=reports.url heading="Reports" %}
+
+        {% render_table reports.table %}
+
+        <div class="all-submissions-table__more">
+            <a href="{{ reports.url }}">Show all</a>
+        </div>
+
+    </div>
+    {% endif %}
 </div>
 
 {% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
index de8cabd3568a81024811c88e2f42cfca7060fe51..10a41bcb50f9673bb999a4f842c4b319c0608daa 100644
--- a/opentech/apply/projects/templates/application_projects/project_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -131,6 +131,12 @@
                 </div>
                 {% endif %}
 
+                {% if object.is_in_progress %}
+                    <div class="wrapper wrapper--outer-space-large">
+                        {% include "application_projects/includes/reports.html" %}
+                    </div>
+                {% endif %}
+
                 {% if not object.is_in_progress %}
                 {% include "application_projects/includes/supporting_documents.html" %}
                 {% endif %}
@@ -263,5 +269,7 @@
     <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script>
     <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script>
     <script src="{% static 'js/apply/file-uploads.js' %}"></script>
+    <script src="{% static 'js/apply/past-reports-pagination.js' %}"></script>
+    <script src="{% static 'js/apply/report-calculator.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/report_detail.html b/opentech/apply/projects/templates/application_projects/report_detail.html
new file mode 100644
index 0000000000000000000000000000000000000000..ccd9ae0c97906186788c668e2f9505fdeb6a79eb
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/report_detail.html
@@ -0,0 +1,44 @@
+{% extends "base-apply.html" %}
+{% load static bleach_tags %}
+
+{% block title %}Report | {{ object.project.title }}{% endblock %}
+{% block body_class %}{% endblock %}
+{% block content %}
+    <div class="admin-bar">
+        <div class="admin-bar__inner">
+            <a class="admin-bar__back-link" href="{{ object.project.get_absolute_url }}">
+                Project
+            </a>
+            <h2 class="heading heading--no-margin">{{ object.project.title }}</h2>
+            <h5 class="heading heading--no-margin">View report</h5>
+        </div>
+    </div>
+
+    <div class="wrapper wrapper--form">
+        {% if report.skipped %}
+            <h2>Report Skipped</h2>
+        {% else %}
+            <h3>Public</h3>
+            <div class="rich-text">
+                {{ object.current.public_content|bleach|safe }}
+            </div>
+
+            <h3>Private</h3>
+            <div class="rich-text">
+                {{ object.current.private_content|bleach|safe }}
+            </div>
+            {% for file in object.current.files.all %}
+                {% if forloop.first %}
+                    <h4>Files</h4>
+                    <ul>
+                {% endif %}
+
+                <li><a href="{{ file.get_absolute_url }}">{{ file.filename }}</a></li>
+
+                {% if forloop.last %}
+                    </ul>
+                {% endif %}
+            {% endfor %}
+        {% endif %}
+    </div>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/report_form.html b/opentech/apply/projects/templates/application_projects/report_form.html
new file mode 100644
index 0000000000000000000000000000000000000000..d161b86d5d75c19127145e09b1c8293f4c30a6b5
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/report_form.html
@@ -0,0 +1,77 @@
+{% extends "base-apply.html" %}
+{% load static %}
+
+{% block extra_css %}
+{{ block.super }}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block title %}Edit Report | {{ object.project.title }}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <a class="admin-bar__back-link" href="{{ object.project.get_absolute_url }}">
+            Project
+        </a>
+        <h2 class="heading heading--no-margin">{{ object.project.title }}</h2>
+        <h5 class="heading heading--no-margin">Submit a report</h5>
+    </div>
+</div>
+
+{% include "forms/includes/form_errors.html" with form=form %}
+
+<div class="wrapper wrapper--light-grey-bg wrapper--form">
+    <div class="wrapper--sidebar--inner">
+
+        <div class="alert">
+            <svg class="alert__icon"><use xlink:href="#exclamation-point"></use></svg>
+            <p class="alert__text">You are reporting for the period running from {{ report.start_date }} to {{ report.end_date }}</p>
+        </div>
+
+        <form class="form" action="" method="post" enctype="multipart/form-data" novalidate>
+            {% csrf_token %}
+            {{ form.media }}
+
+            {% for field in form %}
+                {% if field.field %}
+                    {% include "forms/includes/field.html" %}
+                {% else %}
+                    {{ field }}
+                {% endif %}
+            {% endfor %}
+
+            <input type="submit" id="submit-report-form" class="is-hidden" />
+            <input data-fancybox data-src="#save-report" class="button button--submit button--top-space button--white" type="button" value="Save" />
+            <input data-fancybox data-src="#submit-report" class="button button--primary" type="button" value="Submit" />
+
+            <!-- Save report modal -->
+            <div class="modal" id="save-report">
+                <h4 class="modal__header-bar">Save report</h4>
+                <div class="modal__copy">
+                    <p>Saving a draft means this report will be visible to you and staff from your project page.</p>
+                </div>
+                <div class="modal__buttons">
+                    <button data-fancybox-close class="button button--submit button--white">Cancel</button>
+                    <label class="button button--submit button--top-space button--primary" for="submit-report-form" tabindex="0">Save</label>
+                </div>
+            </div>
+
+            <!-- Submit report modal -->
+            <div class="modal" id="submit-report">
+                <h4 class="modal__header-bar">Submit report</h4>
+                <p>Are you sure you want to submit your report?</p>
+                <div class="modal__buttons">
+                    <button data-fancybox-close class="button button--submit button--white">Cancel</button>
+                    <label class="button button--submit button--top-space button--primary" for="submit-report-form" tabindex="0">Submit</label>
+                </div>
+            </div>
+        </form>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+<script src="{% static 'js/apply/list-input-files.js' %}"></script>
+<script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/report_list.html b/opentech/apply/projects/templates/application_projects/report_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..ab735a8fb348ec34f80ed3d9696d87f4788d7f00
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/report_list.html
@@ -0,0 +1,35 @@
+{% extends "base-apply.html" %}
+
+{% load render_table from django_tables2 %}
+{% load static %}
+
+{% block title %}Reports{% endblock %}
+
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner wrapper--search">
+        {% block page_header %}
+            <h1 class="gamma heading heading--no-margin heading--bold">Submitted Reports</h1>
+        {% endblock %}
+    </div>
+</div>
+
+<div class="wrapper wrapper--large wrapper--inner-space-medium">
+    {% if table %}
+    {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form use_search=False filter_action=filter_action filter_classes="filters--dates" %}
+    {% render_table table %}
+    {% else %}
+    <p>No Reports Available</p>
+    {% endif %}
+</div>
+
+{% endblock content %}
+
+{% block extra_css %}
+    {{ filter.form.media.css }}
+{% endblock %}
+
+{% block extra_js %}
+    {{ filter.form.media.js }}
+    <script src="{% static 'js/apply/submission-filters.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index 23a535ed867e3b44325ac24cb1b5d692c0f67c5b..05a63a64d916310fc0569e805e9c2b1d81e8e481 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -3,17 +3,23 @@ import json
 
 import factory
 import pytz
+from dateutil.relativedelta import relativedelta
 from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
 from opentech.apply.projects.models import (
+    COMPLETE,
     Contract,
     DocumentCategory,
+    IN_PROGRESS,
     PacketFile,
     PaymentReceipt,
     PaymentRequest,
     Project,
     ProjectApprovalForm,
+    Report,
+    ReportConfig,
+    ReportVersion,
 )
 from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory
 from opentech.apply.users.tests.factories import StaffFactory, UserFactory
@@ -93,11 +99,19 @@ class ProjectFactory(factory.DjangoModelFactory):
         in_approval = factory.Trait(
             is_locked=True,
         )
+        in_progress = factory.Trait(
+            status=IN_PROGRESS,
+        )
+        is_complete = factory.Trait(
+            status=COMPLETE,
+        )
 
 
 class ContractFactory(factory.DjangoModelFactory):
     approver = factory.SubFactory(StaffFactory)
     project = factory.SubFactory(ProjectFactory)
+    approved_at = factory.LazyFunction(timezone.now)
+    is_signed = True
 
     file = factory.django.FileField()
 
@@ -137,3 +151,67 @@ class PaymentReceiptFactory(factory.DjangoModelFactory):
 
     class Meta:
         model = PaymentReceipt
+
+
+class ReportConfigFactory(factory.DjangoModelFactory):
+    project = factory.SubFactory(
+        "opentech.apply.projects.tests.factories.ApprovedProjectFactory",
+        report_config=None,
+    )
+
+    class Meta:
+        model = ReportConfig
+        django_get_or_create = ('project',)
+
+    class Params:
+        weeks = factory.Trait(
+            frequency=ReportConfig.WEEK,
+        )
+
+
+class ReportVersionFactory(factory.DjangoModelFactory):
+    report = factory.SubFactory("opentech.apply.projects.tests.factories.ReportFactory")
+    submitted = factory.LazyFunction(timezone.now)
+    public_content = factory.Faker('paragraph')
+    private_content = factory.Faker('paragraph')
+    draft = True
+
+    class Meta:
+        model = ReportVersion
+
+    @factory.post_generation
+    def relate(obj, create, should_relate, **kwargs):
+        if not create:
+            return
+
+        if should_relate:
+            if obj.draft:
+                obj.report.draft = obj
+            else:
+                obj.report.current = obj
+                obj.report.submitted = obj.submitted
+            obj.report.save()
+
+
+class ReportFactory(factory.DjangoModelFactory):
+    project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory")
+    end_date = factory.LazyFunction(timezone.now)
+
+    class Meta:
+        model = Report
+
+    class Params:
+        past_due = factory.Trait(
+            end_date=factory.LazyFunction(lambda: timezone.now() - relativedelta(days=1))
+        )
+        is_submitted = factory.Trait(
+            version=factory.RelatedFactory(ReportVersionFactory, 'report', draft=False, relate=True)
+        )
+        is_draft = factory.Trait(
+            version=factory.RelatedFactory(ReportVersionFactory, 'report', relate=True),
+        )
+
+
+class ApprovedProjectFactory(ProjectFactory):
+    contract = factory.RelatedFactory(ContractFactory, 'project')
+    report_config = factory.RelatedFactory(ReportConfigFactory, 'project')
diff --git a/opentech/apply/projects/tests/test_commands.py b/opentech/apply/projects/tests/test_commands.py
new file mode 100644
index 0000000000000000000000000000000000000000..6d907ab46586d98fba5c78281fac8f401d5c3b32
--- /dev/null
+++ b/opentech/apply/projects/tests/test_commands.py
@@ -0,0 +1,63 @@
+from io import StringIO
+
+from dateutil.relativedelta import relativedelta
+
+from django.core.management import call_command
+from django.test import override_settings, TestCase
+from django.utils import timezone
+
+from opentech.apply.home.models import ApplyHomePage
+
+from .factories import (
+    ProjectFactory,
+    ReportConfigFactory,
+    ReportFactory,
+)
+
+
+@override_settings(ROOT_URLCONF='opentech.apply.urls')
+class TestNotifyReportDue(TestCase):
+    def test_notify_report_due_in_7_days(self):
+        in_a_week = timezone.now() + relativedelta(days=7)
+        ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True)
+        out = StringIO()
+
+        with self.settings(ALLOWED_HOSTS=[ApplyHomePage.objects.first().get_site().hostname]):
+            call_command('notify_report_due', 7, stdout=out)
+        self.assertIn('Notified project', out.getvalue())
+
+    def test_dont_notify_report_due_in_7_days_already_submitted(self):
+        in_a_week = timezone.now() + relativedelta(days=7)
+        config = ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True)
+        ReportFactory(
+            project=config.project,
+            is_submitted=True,
+            end_date=config.schedule_start,
+        )
+        out = StringIO()
+        call_command('notify_report_due', 7, stdout=out)
+        self.assertNotIn('Notified project', out.getvalue())
+
+    def test_dont_notify_already_notified(self):
+        in_a_week = timezone.now() + relativedelta(days=7)
+        config = ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True)
+        ReportFactory(
+            project=config.project,
+            end_date=config.schedule_start,
+            notified=timezone.now(),
+        )
+        out = StringIO()
+        call_command('notify_report_due', 7, stdout=out)
+        self.assertNotIn('Notified project', out.getvalue())
+
+    def test_dont_notify_project_not_in_progress(self):
+        ProjectFactory()
+        out = StringIO()
+        call_command('notify_report_due', 7, stdout=out)
+        self.assertNotIn('Notified project', out.getvalue())
+
+    def test_dont_notify_project_complete(self):
+        ProjectFactory(is_complete=True)
+        out = StringIO()
+        call_command('notify_report_due', 7, stdout=out)
+        self.assertNotIn('Notified project', out.getvalue())
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index a467f9f1dfa2af32f22ea50197d41a8e1da1d56a..3ba12a70e5cc743b25bb01a38b544ce42e1292fb 100644
--- a/opentech/apply/projects/tests/test_models.py
+++ b/opentech/apply/projects/tests/test_models.py
@@ -1,6 +1,8 @@
 from decimal import Decimal
 
+from dateutil.relativedelta import relativedelta
 from django.test import TestCase
+from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
 from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory
@@ -13,12 +15,16 @@ from ..models import (
     UNDER_REVIEW,
     Project,
     PaymentRequest,
+    Report,
+    ReportConfig,
 )
 from .factories import (
     DocumentCategoryFactory,
     PacketFileFactory,
     PaymentRequestFactory,
-    ProjectFactory
+    ProjectFactory,
+    ReportFactory,
+    ReportConfigFactory,
 )
 
 
@@ -168,3 +174,169 @@ class TestPaymentRequestsQueryset(TestCase):
     def test_get_totals_no_value(self):
         self.assertEqual(PaymentRequest.objects.paid_value(), 0)
         self.assertEqual(PaymentRequest.objects.unpaid_value(), 0)
+
+
+class TestReportConfig(TestCase):
+    @property
+    def today(self):
+        return timezone.now().date()
+
+    def test_next_date_month_from_now(self):
+        config = ReportConfigFactory()
+        delta = relativedelta(months=1)
+
+        next_date = config.next_date(self.today)
+
+        self.assertEqual(next_date, self.today + delta)
+
+    def test_next_date_week_from_now(self):
+        config = ReportConfigFactory(frequency=ReportConfig.WEEK)
+        delta = relativedelta(weeks=1)
+
+        next_date = config.next_date(self.today)
+
+        self.assertEqual(next_date, self.today + delta)
+
+    def test_months_always_relative(self):
+        config = ReportConfigFactory(occurrence=2)
+        last_report = self.today - relativedelta(day=25, months=1)
+        next_date = config.next_date(last_report)
+
+        self.assertEqual(next_date, last_report + relativedelta(months=2))
+
+    def test_current_due_report_gets_active_report(self):
+        config = ReportConfigFactory()
+        report = ReportFactory(project=config.project)
+        self.assertEqual(config.current_due_report(), report)
+
+    def test_no_report_creates_report(self):
+        config = ReportConfigFactory()
+        report = config.current_due_report()
+        # Separate day from month for case where start date + 1 month would exceed next month
+        # length (31st Oct to 30th Nov)
+        # combined => 31th + 1 month = 30th - 1 day = 29th (wrong)
+        # separate => 31th - 1 day = 30th + 1 month = 30th (correct)
+        next_due = report.project.start_date - relativedelta(days=1) + relativedelta(months=1)
+        self.assertEqual(Report.objects.count(), 1)
+        self.assertEqual(report.end_date, next_due)
+
+    def test_no_report_creates_report_not_in_past(self):
+        config = ReportConfigFactory(schedule_start=self.today - relativedelta(months=3))
+        report = config.current_due_report()
+        self.assertEqual(Report.objects.count(), 1)
+        self.assertEqual(report.end_date, self.today)
+
+    def test_no_report_schedule_in_future_creates_report(self):
+        config = ReportConfigFactory(schedule_start=self.today + relativedelta(days=2))
+        report = config.current_due_report()
+        self.assertEqual(Report.objects.count(), 1)
+        self.assertEqual(report.end_date, self.today + relativedelta(days=2))
+
+    def test_past_due_report_creates_report(self):
+        config = ReportConfigFactory(schedule_start=self.today - relativedelta(days=2))
+        ReportFactory(project=config.project, end_date=self.today - relativedelta(days=1))
+
+        # Separate day from month for case where start date + 1 month would exceed next month
+        # length (31st Oct to 30th Nov)
+        # combined => 31th + 1 month = 30th - 1 day = 29th (wrong)
+        # separate => 31th - 1 day = 30th + 1 month = 30th (correct)
+        next_due = self.today - relativedelta(days=1) + relativedelta(months=1)
+
+        report = config.current_due_report()
+        self.assertEqual(Report.objects.count(), 2)
+        self.assertEqual(report.end_date, next_due)
+
+    def test_today_schedule_gets_report_today(self):
+        config = ReportConfigFactory(schedule_start=self.today)
+        self.assertEqual(config.current_due_report().end_date, self.today)
+
+    def test_past_due_report_future_schedule_creates_report(self):
+        config = ReportConfigFactory(schedule_start=self.today + relativedelta(days=3))
+        ReportFactory(project=config.project, end_date=self.today - relativedelta(days=1))
+
+        report = config.current_due_report()
+        self.assertEqual(Report.objects.count(), 2)
+        self.assertEqual(report.end_date, self.today + relativedelta(days=3))
+
+    def test_submitted_report_unaffected(self):
+        config = ReportConfigFactory()
+        report = ReportFactory(is_submitted=True, project=config.project, end_date=self.today + relativedelta(days=1))
+        next_report = config.current_due_report()
+        self.assertNotEqual(report, next_report)
+
+    def test_past_due(self):
+        report = ReportFactory(past_due=True)
+        config = report.project.report_config
+        self.assertQuerysetEqual(config.past_due_reports(), [report], transform=lambda x: x)
+
+    def test_past_due_has_drafts(self):
+        report = ReportFactory(past_due=True, is_draft=True)
+        config = report.project.report_config
+        self.assertQuerysetEqual(config.past_due_reports(), [report], transform=lambda x: x)
+
+    def test_past_due_no_submitted(self):
+        report = ReportFactory(is_submitted=True, past_due=True)
+        config = report.project.report_config
+        self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x)
+
+    def test_past_due_no_future(self):
+        report = ReportFactory(end_date=self.today + relativedelta(days=1))
+        config = report.project.report_config
+        self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x)
+
+    def test_past_due_no_skipped(self):
+        report = ReportFactory(skipped=True, past_due=True)
+        config = report.project.report_config
+        self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x)
+
+
+class TestReport(TestCase):
+    @property
+    def today(self):
+        return timezone.now().date()
+
+    def from_today(self, days):
+        return self.today + relativedelta(days=days)
+
+    def test_not_late_if_one_ahead(self):
+        report = ReportFactory(end_date=self.from_today(-3))
+        ReportFactory(project=report.project)
+        self.assertFalse(report.is_very_late)
+
+    def test_late_if_two_weeks_behind(self):
+        report = ReportFactory(end_date=self.from_today(-15))
+        self.assertTrue(report.is_very_late)
+
+    def test_not_late_if_two_ahead_but_one_in_future(self):
+        report = ReportFactory(end_date=self.from_today(-3))
+        ReportFactory(project=report.project)
+        ReportFactory(end_date=self.from_today(2), project=report.project)
+        self.assertFalse(report.is_very_late)
+
+    def test_start_date(self):
+        yesterday = self.from_today(-1)
+        ReportFactory(end_date=yesterday)
+        report = ReportFactory(end_date=self.from_today(1))
+        self.assertEqual(report.start_date, self.today)
+
+    def test_start_date_with_submitted(self):
+        yesterday = self.from_today(-1)
+        ReportFactory(end_date=yesterday)
+        report = ReportFactory(end_date=self.from_today(1), is_submitted=True)
+        self.assertEqual(report.start_date, self.today)
+
+    def test_queryset_done_includes_submitted(self):
+        report = ReportFactory(is_submitted=True)
+        self.assertQuerysetEqual(Report.objects.done(), [report], transform=lambda x: x)
+
+    def test_queryset_done_includes_skipped(self):
+        report = ReportFactory(skipped=True)
+        self.assertQuerysetEqual(Report.objects.done(), [report], transform=lambda x: x)
+
+    def test_queryset_done_doesnt_includes_draft(self):
+        ReportFactory(is_draft=True)
+        self.assertQuerysetEqual(Report.objects.done(), [], transform=lambda x: x)
+
+    def test_queryset_done_doesnt_includes_to_do(self):
+        ReportFactory()
+        self.assertQuerysetEqual(Report.objects.done(), [], transform=lambda x: x)
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index 8d84549be4d9b8766df977d36034df4130f61501..a6364605ab896ae711313e0ee18970bdf2c39c6e 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -1,10 +1,12 @@
 from decimal import Decimal
 from io import BytesIO
+from dateutil.relativedelta import relativedelta
 
 from django.contrib.auth.models import AnonymousUser
 from django.core.exceptions import PermissionDenied
 from django.test import RequestFactory, TestCase, override_settings
 from django.urls import reverse
+from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import LabSubmissionFactory
 from opentech.apply.users.tests.factories import (
@@ -33,7 +35,9 @@ from .factories import (
     PacketFileFactory,
     PaymentReceiptFactory,
     PaymentRequestFactory,
-    ProjectFactory
+    ProjectFactory,
+    ReportFactory,
+    ReportVersionFactory,
 )
 
 
@@ -1234,3 +1238,254 @@ class TestProjectOverviewView(TestCase):
 
         response = self.client.get(url, follow=True)
         self.assertEqual(response.status_code, 403)
+
+
+class TestStaffSubmitReport(BaseViewTestCase):
+    base_view_name = 'edit'
+    url_name = 'funds:projects:reports:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_get_page(self):
+        report = ReportFactory()
+        response = self.get_page(report)
+        self.assertContains(response, report.project.title)
+
+    def test_submit_report(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'public_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().public_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
+        self.assertEqual(report.current.author, self.user)
+        self.assertIsNone(report.draft)
+
+    def test_submit_private_report(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'private_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().private_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
+        self.assertEqual(report.current.author, self.user)
+        self.assertIsNone(report.draft)
+
+    def test_cant_submit_blank_report(self):
+        report = ReportFactory()
+        response = self.post_page(report, {})
+        report.refresh_from_db()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(report.versions.count(), 0)
+
+    def test_save_report_draft(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'public_content': 'Some text', 'save': 'Save'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().public_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.draft)
+        self.assertIsNone(report.current)
+
+    def test_save_report_with_draft(self):
+        report = ReportFactory(is_draft=True)
+        self.assertEqual(report.versions.first(), report.draft)
+        response = self.post_page(report, {'public_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.last().public_content, 'Some text')
+        self.assertEqual(report.versions.last(), report.current)
+        self.assertIsNone(report.draft)
+
+    def test_edit_submitted_report(self):
+        report = ReportFactory(is_submitted=True)
+        self.assertEqual(report.versions.first(), report.current)
+        response = self.post_page(report, {'public_content': 'Some text', 'save': ' Save'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.last().public_content, 'Some text')
+        self.assertEqual(report.versions.last(), report.draft)
+        self.assertEqual(report.versions.first(), report.current)
+
+    def test_resubmit_submitted_report(self):
+        yesterday = timezone.now() - relativedelta(days=1)
+        version = ReportVersionFactory(submitted=yesterday)
+        report = version.report
+        report.current = version
+        report.submitted = version.submitted
+        report.save()
+        self.assertEqual(report.submitted, yesterday)
+        self.assertEqual(report.versions.first(), report.current)
+        response = self.post_page(report, {'public_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.last().public_content, 'Some text')
+        self.assertEqual(report.versions.last(), report.current)
+        self.assertIsNone(report.draft)
+        self.assertEqual(report.submitted.date(), yesterday.date())
+        self.assertEqual(report.current.submitted.date(), timezone.now().date())
+
+    def test_cant_submit_future_report(self):
+        submitted_report = ReportFactory(
+            end_date=timezone.now() + relativedelta(days=1),
+            is_submitted=True,
+        )
+        future_report = ReportFactory(
+            end_date=timezone.now() + relativedelta(days=3),
+            project=submitted_report.project,
+        )
+        response = self.post_page(future_report, {'public_content': 'Some text'})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantSubmitReport(BaseViewTestCase):
+    base_view_name = 'edit'
+    url_name = 'funds:projects:reports:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_get_own_report(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.get_page(report)
+        self.assertContains(response, report.project.title)
+
+    def test_cant_get_other_report(self):
+        report = ReportFactory()
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 403)
+
+    def test_submit_own_report(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.post_page(report, {'public_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().public_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
+        self.assertEqual(report.current.author, self.user)
+
+    def test_submit_private_report(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.post_page(report, {'private_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().private_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.current)
+        self.assertEqual(report.current.author, self.user)
+        self.assertIsNone(report.draft)
+
+    def test_cant_submit_blank_report(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.post_page(report, {})
+        report.refresh_from_db()
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(report.versions.count(), 0)
+
+    def test_save_report_draft(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.post_page(report, {'public_content': 'Some text', 'save': 'Save'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().public_content, 'Some text')
+        self.assertEqual(report.versions.first(), report.draft)
+        self.assertIsNone(report.current)
+
+    def test_save_report_with_draft(self):
+        report = ReportFactory(is_draft=True, project__user=self.user)
+        self.assertEqual(report.versions.first(), report.draft)
+        response = self.post_page(report, {'public_content': 'Some text'})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.last().public_content, 'Some text')
+        self.assertEqual(report.versions.last(), report.current)
+        self.assertIsNone(report.draft)
+
+    def test_cant_edit_submitted_report(self):
+        report = ReportFactory(is_submitted=True, project__user=self.user)
+        self.assertEqual(report.versions.first(), report.current)
+        response = self.post_page(report, {'public_content': 'Some text', 'save': ' Save'})
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_submit_other_report(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'public_content': 'Some text'})
+        self.assertEqual(response.status_code, 403)
+
+
+class TestStaffReportDetail(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:reports:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can_access_submitted_report(self):
+        report = ReportFactory(is_submitted=True)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 200)
+
+    def test_can_access_skipped_report(self):
+        report = ReportFactory(skipped=True)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 200)
+
+    def test_cant_access_draft_report(self):
+        report = ReportFactory(is_draft=True)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_access_future_report(self):
+        report = ReportFactory(end_date=timezone.now() + relativedelta(days=1))
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantReportDetail(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:reports:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.pk,
+        }
+
+    def test_can_access_own_submitted_report(self):
+        report = ReportFactory(is_submitted=True, project__user=self.user)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 200)
+
+    def test_cant_access_own_draft_report(self):
+        report = ReportFactory(is_draft=True, project__user=self.user)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_access_own_future_report(self):
+        report = ReportFactory(end_date=timezone.now() + relativedelta(days=1), project__user=self.user)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_access_other_submitted_report(self):
+        report = ReportFactory(is_submitted=True)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 200)
+
+    def test_cant_access_other_draft_report(self):
+        report = ReportFactory(is_draft=True)
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_access_other_future_report(self):
+        report = ReportFactory(end_date=timezone.now() + relativedelta(days=1))
+        response = self.get_page(report)
+        self.assertEqual(response.status_code, 404)
diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py
index 3a0e838a8a34a1748fac2a46f85fac1909015eb1..41d28497532b41fe68ee4e7d142d6ebc1ffb30a1 100644
--- a/opentech/apply/projects/urls.py
+++ b/opentech/apply/projects/urls.py
@@ -14,6 +14,11 @@ from .views import (
     ProjectListView,
     ProjectOverviewView,
     ProjectPrivateMediaView,
+    ReportDetailView,
+    ReportListView,
+    ReportPrivateMedia,
+    ReportSkipView,
+    ReportUpdateView,
 )
 
 app_name = 'projects'
@@ -39,4 +44,13 @@ urlpatterns = [
             path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"),
         ])),
     ], 'payments'))),
+    path('reports/', include(([
+        path('', ReportListView.as_view(), name='all'),
+        path('<int:pk>/', include([
+            path('', ReportDetailView.as_view(), name='detail'),
+            path('skip/', ReportSkipView.as_view(), name='skip'),
+            path('edit/', ReportUpdateView.as_view(), name='edit'),
+            path('documents/<int:file_pk>/', ReportPrivateMedia.as_view(), name="document"),
+        ])),
+    ], 'reports'))),
 ]
diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py
index fa45c29c176b0b33d5b01a6e2e926dc9f487fbc7..940f89395e695b1bb720a89a1430f497e9257bf9 100644
--- a/opentech/apply/projects/views/__init__.py
+++ b/opentech/apply/projects/views/__init__.py
@@ -1,2 +1,3 @@
 from .payment import *  # NOQA
 from .project import *  # NOQA
+from .report import *  # NOQA
diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py
index 88cf33d6b778495279bcb8380fd7741f2b75ef03..4f5c8966c8f37f084e6c9a0bf4f1fab306a54f46 100644
--- a/opentech/apply/projects/views/project.py
+++ b/opentech/apply/projects/views/project.py
@@ -9,6 +9,7 @@ from django.db.models import Count
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect
 from django.urls import reverse, reverse_lazy
+from django.utils import timezone
 from django.utils.decorators import method_decorator
 from django.utils.functional import cached_property
 from django.utils.text import mark_safe
@@ -37,6 +38,7 @@ from ..files import get_files
 from ..filters import (
     PaymentRequestListFilter,
     ProjectListFilter,
+    ReportListFilter,
 )
 from ..forms import (
     ApproveContractForm,
@@ -60,13 +62,17 @@ from ..models import (
     Contract,
     PacketFile,
     PaymentRequest,
-    Project
+    Project,
+    Report,
 )
 from ..tables import (
     PaymentRequestsListTable,
-    ProjectsListTable
+    ProjectsListTable,
+    ReportListTable,
 )
 
+from .report import ReportingMixin, ReportFrequencyUpdate
+
 
 # APPROVAL VIEWS
 
@@ -319,6 +325,7 @@ class ApproveContractView(DelegatedViewMixin, UpdateView):
     def form_valid(self, form):
         with transaction.atomic():
             form.instance.approver = self.request.user
+            form.instance.approved_at = timezone.now()
             form.instance.project = self.project
             response = super().form_valid(form)
 
@@ -397,7 +404,7 @@ class UploadContractView(DelegatedViewMixin, CreateView):
 
 
 # PROJECT VIEW
-class BaseProjectDetailView(DetailView):
+class BaseProjectDetailView(ReportingMixin, DetailView):
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context['statuses'] = PROJECT_STATUS_CHOICES
@@ -419,6 +426,7 @@ class AdminProjectDetailView(
         RemoveDocumentView,
         SelectDocumentView,
         SendForApprovalView,
+        ReportFrequencyUpdate,
         UpdateLeadView,
         UploadContractView,
         UploadDocumentView,
@@ -430,6 +438,12 @@ class AdminProjectDetailView(
         context = super().get_context_data(**kwargs)
         context['approvals'] = self.object.approvals.distinct('by')
         context['remaining_document_categories'] = list(self.object.get_missing_document_categories())
+
+        if self.object.is_in_progress:
+            context['report_data'] = {
+                'startDate': self.object.report_config.current_due_report().start_date,
+                'projectEndDate': self.object.end_date,
+            }
         return context
 
 
@@ -587,13 +601,10 @@ class ProjectEditView(ViewDispatcher):
 @method_decorator(staff_required, name='dispatch')
 class ProjectListView(SingleTableMixin, FilterView):
     filterset_class = ProjectListFilter
-    model = Project
+    queryset = Project.objects.for_table()
     table_class = ProjectsListTable
     template_name = 'application_projects/project_list.html'
 
-    def get_queryset(self):
-        return Project.objects.for_table()
-
 
 @method_decorator(staff_required, name='dispatch')
 class ProjectOverviewView(TemplateView):
@@ -603,9 +614,18 @@ class ProjectOverviewView(TemplateView):
         context = super().get_context_data(**kwargs)
         context['projects'] = self.get_projects(self.request)
         context['payment_requests'] = self.get_payment_requests(self.request)
+        context['reports'] = self.get_reports(self.request)
         context['status_counts'] = self.get_status_counts()
         return context
 
+    def get_reports(self, request):
+        reports = Report.objects.for_table().submitted()[:10]
+        return {
+            'filterset': ReportListFilter(request.GET or None, request=request, queryset=reports),
+            'table': ReportListTable(reports, order_by=()),
+            'url': reverse('apply:projects:reports:all'),
+        }
+
     def get_payment_requests(self, request):
         payment_requests = PaymentRequest.objects.order_by('date_to')[:10]
 
diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py
new file mode 100644
index 0000000000000000000000000000000000000000..b76779ef6c9b90295926a1325840ef6800f5e756
--- /dev/null
+++ b/opentech/apply/projects/views/report.py
@@ -0,0 +1,211 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import PermissionDenied
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.decorators import method_decorator
+from django.views import View
+from django.views.generic import (
+    DetailView,
+    UpdateView,
+)
+from django.views.generic.detail import SingleObjectMixin
+from django_filters.views import FilterView
+from django_tables2 import SingleTableMixin
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.utils.storage import PrivateMediaView
+from opentech.apply.utils.views import DelegatedViewMixin
+from opentech.apply.users.decorators import staff_required
+
+from ..filters import ReportListFilter
+from ..forms import (
+    ReportEditForm,
+    ReportFrequencyForm,
+)
+from ..models import Report, ReportConfig, ReportPrivateFiles
+from ..tables import ReportListTable
+
+
+class ReportingMixin:
+    def dispatch(self, *args, **kwargs):
+        project = self.get_object()
+        if project.is_in_progress:
+            if not hasattr(project, 'report_config'):
+                ReportConfig.objects.create(project=project)
+
+        return super().dispatch(*args, **kwargs)
+
+
+class ReportAccessMixin:
+    def dispatch(self, request, *args, **kwargs):
+        is_admin = request.user.is_apply_staff
+        is_owner = request.user == self.get_object().project.user
+        if not (is_owner or is_admin):
+            raise PermissionDenied
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+@method_decorator(login_required, name='dispatch')
+class ReportDetailView(ReportAccessMixin, DetailView):
+    model = Report
+
+    def dispatch(self, *args, **kwargs):
+        report = self.get_object()
+        if not report.current and not report.skipped:
+            raise Http404
+        return super().dispatch(*args, **kwargs)
+
+
+@method_decorator(login_required, name='dispatch')
+class ReportUpdateView(ReportAccessMixin, UpdateView):
+    form_class = ReportEditForm
+    model = Report
+
+    def dispatch(self, *args, **kwargs):
+        report = self.get_object()
+        if not report.can_submit:
+            raise Http404
+        if report.current and self.request.user.is_applicant:
+            raise Http404
+        return super().dispatch(*args, **kwargs)
+
+    def get_initial(self):
+        if self.object.draft:
+            current = self.object.draft
+        else:
+            current = self.object.current
+
+        if current:
+            return {
+                'public_content': current.public_content,
+                'private_content': current.private_content,
+                'file_list': current.files.all(),
+            }
+
+        return {}
+
+    def get_form_kwargs(self):
+        return {
+            'user': self.request.user,
+            **super().get_form_kwargs(),
+        }
+
+    def get_success_url(self):
+        return self.object.project.get_absolute_url()
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        should_notify = True
+        if self.object.draft:
+            # It was a draft submission
+            should_notify = False
+        else:
+            if self.object.submitted != self.object.current.submitted:
+                # It was a staff edit - post submission
+                should_notify = False
+
+        if should_notify:
+            messenger(
+                MESSAGES.SUBMIT_REPORT,
+                request=self.request,
+                user=self.request.user,
+                source=self.object.project,
+                related=self.object,
+            )
+
+        return response
+
+
+@method_decorator(login_required, name='dispatch')
+class ReportPrivateMedia(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        report_pk = self.kwargs['pk']
+        self.report = get_object_or_404(Report, pk=report_pk)
+        file_pk = kwargs.get('file_pk')
+        self.document = get_object_or_404(
+            ReportPrivateFiles.objects,
+            report__report=self.report,
+            pk=file_pk
+        )
+
+        if not hasattr(self.document.report, 'live_for_report'):
+            # this is not a document in the live report
+            # send the user to the report page to see latest version
+            return redirect(self.report.get_absolute_url())
+
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        return self.document.document
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.report.project.user:
+            return True
+
+        return False
+
+
+@method_decorator(staff_required, name='dispatch')
+class ReportSkipView(SingleObjectMixin, View):
+    model = Report
+
+    def post(self, *args, **kwargs):
+        report = self.get_object()
+        if not report.current:
+            report.skipped = not report.skipped
+            report.save()
+            messenger(
+                MESSAGES.SKIPPED_REPORT,
+                request=self.request,
+                user=self.request.user,
+                source=report.project,
+                related=report,
+            )
+        return redirect(report.project.get_absolute_url())
+
+
+@method_decorator(staff_required, name='dispatch')
+class ReportFrequencyUpdate(DelegatedViewMixin, UpdateView):
+    form_class = ReportFrequencyForm
+    context_name = 'update_frequency_form'
+    model = ReportConfig
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('user')
+        kwargs['instance'] = kwargs['instance'].report_config
+        return kwargs
+
+    def get_form(self):
+        if self.get_parent_object().is_in_progress:
+            return super().get_form()
+        return None
+
+    def form_valid(self, form):
+        config = form.instance
+        response = super().form_valid(form)
+        messenger(
+            MESSAGES.REPORT_FREQUENCY_CHANGED,
+            request=self.request,
+            user=self.request.user,
+            source=config.project,
+            related=config,
+        )
+
+        return response
+
+
+@method_decorator(staff_required, name='dispatch')
+class ReportListView(SingleTableMixin, FilterView):
+    queryset = Report.objects.submitted().for_table()
+    filterset_class = ReportListFilter
+    table_class = ReportListTable
+    template_name = 'application_projects/report_list.html'
diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py
index 045438cefae3c3480053ff85c6c5ad82e803440e..0cf89a20b98bf1a940789ef23508e50b378b8fb5 100644
--- a/opentech/apply/stream_forms/testing/factories.py
+++ b/opentech/apply/stream_forms/testing/factories.py
@@ -212,7 +212,7 @@ class DropdownFieldBlockFactory(FormFieldBlockFactory):
 
 
 class UploadableMediaFactory(FormFieldBlockFactory):
-    default_value = factory.django.FileField
+    default_value = factory.django.FileField()
 
     @classmethod
     def make_answer(cls, params=None):
@@ -220,12 +220,12 @@ class UploadableMediaFactory(FormFieldBlockFactory):
         params.setdefault('data', b'this is some content')
         if params.get('filename') is None:
             params['filename'] = 'example.pdf'
-        file_name, file = cls.default_value()._make_content(params)
+        file_name, file = cls.default_value._make_content(params)
         return SimpleUploadedFile(file_name, file.read())
 
 
 class ImageFieldBlockFactory(UploadableMediaFactory):
-    default_value = factory.django.ImageField
+    default_value = factory.django.ImageField()
 
     class Meta:
         model = stream_blocks.ImageFieldBlock
diff --git a/opentech/apply/utils/fields.py b/opentech/apply/utils/fields.py
new file mode 100644
index 0000000000000000000000000000000000000000..952f37088488f151cc3cdd0b3c19bfb71c58d6dc
--- /dev/null
+++ b/opentech/apply/utils/fields.py
@@ -0,0 +1,11 @@
+from django import forms
+
+from .options import RICH_TEXT_WIDGET
+
+
+class RichTextField(forms.CharField):
+    widget = RICH_TEXT_WIDGET
+
+    def __init__(self, *args, required=False, **kwargs):
+        kwargs.update(required=required)
+        super().__init__(*args, **kwargs)
diff --git a/opentech/static_src/src/javascript/apply/past-reports-pagination.js b/opentech/static_src/src/javascript/apply/past-reports-pagination.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef1a1b41a1b3409fdf08d0579697d3f644101c97
--- /dev/null
+++ b/opentech/static_src/src/javascript/apply/past-reports-pagination.js
@@ -0,0 +1,27 @@
+(function ($) {
+
+    'use strict';
+
+    function pastReportsPagination() {
+        $('.js-data-block-pagination').click((e) => {
+            e.preventDefault();
+            showNextTen();
+        });
+    }
+
+    function showNextTen() {
+        const [...nextTen] = $('.js-past-reports-table tr.is-hidden').slice(0, 10);
+        nextTen.forEach(item => item.classList.remove('is-hidden'));
+        checkRemaining();
+    }
+
+    function checkRemaining() {
+        const [...remaining] = $('.js-past-reports-table tr.is-hidden');
+        if (remaining.length === 0) {
+            $('.js-data-block-pagination').addClass('is-hidden');
+        }
+    }
+
+    pastReportsPagination();
+
+})(jQuery);
diff --git a/opentech/static_src/src/javascript/apply/report-calculator.js b/opentech/static_src/src/javascript/apply/report-calculator.js
new file mode 100644
index 0000000000000000000000000000000000000000..c3a038c3e5a2d7d247053086f4d78ed7150690ee
--- /dev/null
+++ b/opentech/static_src/src/javascript/apply/report-calculator.js
@@ -0,0 +1,102 @@
+(function ($) {
+
+    'use strict';
+
+    const reportData = JSON.parse(document.getElementById('reportData').textContent);
+
+    // Form inputs
+    const frequencyNumberInput = document.getElementById('id_occurrence');
+    const frequencyPeriodSelect = document.getElementById('id_frequency');
+    const startDateInput = document.getElementById('id_start');
+
+    // Form slots
+    const projectEndSlot = document.querySelector('.js-project-end-slot');
+    const frequencyNumberSlot = document.querySelector('.js-frequency-number-slot');
+    const frequencyPeriodSlot = document.querySelector('.js-frequency-period-slot');
+    const periodStartSlot = document.querySelector('.js-report-period-start');
+    const periodEndSlot = document.querySelector('.js-report-period-end');
+    const nextReportDueSlot = document.querySelector('.js-next-report-due-slot');
+
+    function init() {
+        // Set on page load
+        setProjectEnd();
+        setFrequency();
+        setReportPeriodStart();
+
+        // Add event listeners
+        addFrequencyEvents();
+        addReportPeriodEvents();
+    }
+
+    // Sets the project end date in the form info box
+    function setProjectEnd() {
+        projectEndSlot.innerHTML = reportData.projectEndDate;
+    }
+
+    // Set the reporting frequency
+    function setFrequency() {
+        frequencyNumberSlot.innerHTML = frequencyNumberInput.value;
+        pluraliseTimePeriod(frequencyNumberInput.value);
+    }
+
+    // Set the reporting period start date 
+    function setReportPeriodStart() {
+        const startDate = new Date(reportData.startDate);
+        periodStartSlot.innerHTML = startDate.toISOString().slice(0, 10);
+    }
+
+    function addReportPeriodEvents() {
+        startDateInput.oninput = e => {
+            // Update the reporting period end date (next report date)
+            periodEndSlot.innerHTML = e.target.value;
+
+            // Update the reporting period range (next report date - today)
+            const daysDiff = dateDiffInDays(new Date(), new Date(e.target.value));
+            const weeksAndDays = getWeeks(daysDiff);
+            const {weeks, days} = weeksAndDays;
+            const pluraliseWeeks = weeks === 1 ? '' : 's';
+            const pluraliseDays = days === 1 ? '' : 's';
+
+            nextReportDueSlot.innerHTML = `
+                ${weeks > 0 ? `${weeks} week${pluraliseWeeks}` : ''} ${days} day${pluraliseDays}
+            `;
+        };
+    }
+
+    // Update reporting frequency as the options are changed
+    function addFrequencyEvents() {
+        frequencyNumberInput.oninput = e => {
+            frequencyNumberSlot.innerHTML = e.target.value;
+            pluraliseTimePeriod(e.target.value);
+        };
+
+        frequencyPeriodSelect.onchange = e => {
+            frequencyPeriodSlot.innerHTML = `${e.target.value}`;
+            pluraliseTimePeriod(frequencyNumberInput.value);
+        };
+    }
+
+    function pluraliseTimePeriod(number) {
+        frequencyPeriodSlot.innerHTML = `${frequencyPeriodSelect.value}${Number(number) === 1 ? '' : 's'}`;
+    }
+
+    // Get the number of days between two dates
+    function dateDiffInDays(startDate, EndDate) {
+        const msPerDay = 1000 * 60 * 60 * 24;
+        const utc1 = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
+        const utc2 = Date.UTC(EndDate.getFullYear(), EndDate.getMonth(), EndDate.getDate());
+
+        return Math.floor((utc2 - utc1) / msPerDay);
+    }
+
+    // Convert days into weeks and days
+    function getWeeks(days) {
+        return {
+            weeks: Math.floor(days / 7),
+            days: days % 7
+        };
+    }
+
+    init();
+
+})(jQuery);
diff --git a/opentech/static_src/src/sass/apply/components/_admin-bar.scss b/opentech/static_src/src/sass/apply/components/_admin-bar.scss
index ef371d1a796b90145ab925a322066cb3ea3cf1c0..4004a9d6827f4fa5aebea800fcbe4d4a01cf1cee 100644
--- a/opentech/static_src/src/sass/apply/components/_admin-bar.scss
+++ b/opentech/static_src/src/sass/apply/components/_admin-bar.scss
@@ -36,4 +36,18 @@
             margin: 0 5px;
         }
     }
+
+    &__back-link {
+        display: inline-flex;
+        align-items: center;
+        color: $color--lightest-blue;
+        font-weight: $weight--bold;
+
+        &::before {
+            @include triangle(top, currentColor, 5px);
+            margin-right: .5rem;
+            transform: rotate(-90deg);
+        }
+    }
+
 }
diff --git a/opentech/static_src/src/sass/apply/components/_alert.scss b/opentech/static_src/src/sass/apply/components/_alert.scss
new file mode 100644
index 0000000000000000000000000000000000000000..9c1997b5e6fe28000fd9986286f5cc3f51c73371
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_alert.scss
@@ -0,0 +1,24 @@
+.alert {
+    border: 2px solid $color--lighter-blue;
+    padding: 1rem;
+    margin: 0 0 2rem;
+    background-color: $color--light-blue-90;
+    display: flex;
+    align-items: center;
+
+    @include media-query(tablet-portrait) {
+        padding: 1.5rem;
+    }
+
+    &__icon {
+        width: 25px;
+        height: 25px;
+        fill: $color--light-blue;
+        margin-right: .8rem;
+    }
+
+    &__text {
+        margin: 0;
+        flex: 1;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_data-block.scss b/opentech/static_src/src/sass/apply/components/_data-block.scss
new file mode 100644
index 0000000000000000000000000000000000000000..b7c4152f4603671613d9129d23bce68e10990364
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_data-block.scss
@@ -0,0 +1,256 @@
+.data-block {
+    $root: &;
+    margin-bottom: 1rem;
+
+    &__header {
+        padding: 1rem;
+        background-color: $color--primary;
+
+        @include media-query(mob-landscape) {
+            padding: 1rem 2rem;
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+    }
+
+    &__body {
+        border: 1px solid $color--mid-grey;
+        border-top: 0;
+        padding: 1rem;
+
+        @include media-query(mob-landscape) {
+            padding: 2rem;
+        }
+    }
+
+    &__title {
+        font-size: map-get($font-sizes, delta);
+        margin: 0 0 1rem;
+        color: $color--white;
+
+        @include media-query(mob-landscape) {
+            margin: 0;
+        }
+    }
+
+    &__button {
+        padding: .7rem 1.2rem;
+
+        #{$root}__header & {
+            background-color: $color--white;
+            color: $color--primary;
+
+            &:hover,
+            &:focus {
+                background-color: $color--white;
+            }
+        }
+
+        #{$root}__links & {
+            margin-right: 1rem;
+
+            &:only-child {
+                margin-right: 0;
+            }
+        }
+    }
+
+    &__status {
+        margin: 0;
+
+        @include media-query(tablet-landscape) {
+            display: block;
+            font-weight: $weight--bold;
+        }
+    }
+
+    &__pagination,
+    &__rejected {
+        text-align: center;
+    }
+
+    &__pagination-link,
+    &__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: left;
+                }
+            }
+
+            tr {
+                border-color: $color--light-mid-grey;
+            }
+        }
+
+        tbody {
+            font-size: map-get($font-sizes, zeta);
+        }
+
+        tr {
+            border: 0;
+            border-bottom: 2px solid $color--light-grey;
+
+            &:hover {
+                box-shadow: none;
+            }
+
+            td {
+                padding: 0 0 .5rem;
+                word-break: break-word;
+
+                @include media-query(tablet-landscape) {
+                    padding: 1rem;
+                }
+
+                &:first-child {
+                    padding: 1rem 0 .5rem;
+
+                    @include media-query(tablet-landscape) {
+                        padding: 1rem;
+                    }
+                }
+            }
+        }
+    }
+
+    &__table-amount {
+        width: 25%;
+        min-width: 90px;
+    }
+
+    &__table-status {
+        min-width: 160px;
+        width: 25%;
+    }
+
+    &__table-date {
+        min-width: 180px;
+        width: 25%;
+    }
+
+    &__table-update {
+        min-width: 160px;
+        width: 20%;
+
+        @include media-query(desktop) {
+            width: 30%;
+        }
+    }
+
+    &__action-link {
+        font-size: map-get($font-sizes, zeta);
+        display: inline-block;
+        margin-right: 1rem;
+        text-decoration: underline;
+        color: $color--primary;
+        word-break: normal;
+
+        &:last-child {
+            margin: 0;
+        }
+    }
+
+    &__list-item {
+        border-bottom: 2px solid $color--light-grey;
+        padding: 1rem 0;
+
+        @include media-query(tablet-landscape) {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+        }
+
+        &:first-child {
+            padding-top: 0;
+        }
+
+        &:last-child {
+            border-bottom: 0;
+        }
+
+        &:only-child {
+            padding: 0;
+        }
+    }
+
+    &__info {
+        margin: 0 1rem 1rem 0;
+
+        @include media-query(tablet-landscape) {
+            margin: 0 1rem 0 0;
+            flex: 1;
+        }
+    }
+
+    &__links {
+        display: flex;
+        align-items: center;
+
+        @include media-query(tablet-landscape) {
+            justify-content: flex-end;
+        }
+    }
+
+    &__icon {
+        width: 25px;
+        height: 25px;
+        fill: $color--tomato;
+    }
+
+    &__card {
+        padding-bottom: 1rem;
+        position: relative;
+        margin-bottom: 2rem;
+
+        &::after {
+            content: '';
+            width: calc(100% + 2rem);
+            position: absolute;
+            height: 2px;
+            display: block;
+            background: $color--mid-grey;
+            left: -1rem;
+            bottom: 0;
+
+            @include media-query(mob-landscape) {
+                width: calc(100% + 4rem);
+                left: -2rem;
+            }
+        }
+    }
+
+    &__card-copy,
+    &__card-title {
+        margin: 0 0 .5rem;
+    }
+
+    &__card-title {
+        font-weight: $weight--bold;
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_form.scss b/opentech/static_src/src/sass/apply/components/_form.scss
index ca166d9d4360a96664a6082a35ea52de2de16359..896eaba00a0f3bb8334a0f0d672714a6b1a21487 100644
--- a/opentech/static_src/src/sass/apply/components/_form.scss
+++ b/opentech/static_src/src/sass/apply/components/_form.scss
@@ -43,6 +43,23 @@
             }
         }
 
+        #{$root}--report-frequency & {
+            margin: 0;
+
+            // Number input
+            &:nth-of-type(1) {
+                width: 20%;
+                display: inline-block;
+                margin-right: 1rem;
+            }
+
+            // Frequency select
+            &:nth-of-type(2) {
+                width: 50%;
+                display: inline-block;
+            }
+        }
+
         &--wrap {
             flex-wrap: wrap;
         }
@@ -151,10 +168,20 @@
                 opacity: 1;
                 transition-delay: $base-delay * 10;
             }
+
+            .filters--dates & {
+                align-items: flex-end;
+                margin: 10px 0 30px;
+                padding: 0;
+            }
         }
 
         label {
             display: none;
+
+            .filters--dates & {
+                display: block;
+            }
         }
 
         // so the form can be output in any tag
@@ -178,6 +205,58 @@
                 margin: 0;
             }
         }
+
+        > li {
+            padding: 0 1rem;
+
+            @include media-query(tablet-landscape) {
+                padding: 0;
+            }
+
+            // re-order from/to date inputs and text
+            .filters--dates & {
+                margin: 0 auto 1rem;
+                max-width: 320px;
+
+                @include media-query(mob-landscape) {
+                    display: flex;
+                    max-width: 600px;
+
+                    @supports (display: grid) {
+                        display: grid;
+                        grid-template-columns: 1fr 1fr;
+                        grid-gap: 5px;
+                    }
+                }
+
+                @include media-query(tablet-landscape) {
+                    margin: 0 1rem 0 0;
+                    max-width: initial;
+                }
+
+                label {
+                    @supports (display: grid) {
+                        grid-column: 1;
+                        grid-row: 1;
+                    }
+                }
+
+                input {
+                    &:first-of-type {
+                        @supports (display: grid) {
+                            grid-column: 1;
+                        }
+                    }
+                }
+
+                span {
+                    @supports (display: grid) {
+                        grid-column: 2;
+                        grid-row: 1;
+                    }
+                }
+            }
+        }
     }
 
     &__label {
@@ -469,4 +548,13 @@
         border: 1px solid $color--mid-grey;
         max-width: 410px;
     }
+
+    &__info-box {
+        background-color: $color--light-blue-90;
+        padding: 1rem;
+
+        p {
+            margin: 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
deleted file mode 100644
index b6f48c039dfadd578cca09e899a9e7d37cf02b93..0000000000000000000000000000000000000000
--- a/opentech/static_src/src/sass/apply/components/_payment-block.scss
+++ /dev/null
@@ -1,152 +0,0 @@
-.payment-block {
-    border: 1px solid $color--mid-grey;
-    padding: 1rem;
-    margin-bottom: 1rem;
-
-    @include media-query(mob-landscape) {
-        padding: 2rem;
-    }
-
-    &__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;
-        }
-    }
-
-    &__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: left;
-                }
-            }
-
-            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;
-                    display: flex;
-                    flex-wrap: wrap;
-
-                    & > * {
-                        flex: 1 1 55px;
-                        max-width: 55px;
-                    }
-                }
-
-                @include media-query(tablet-landscape) {
-                    padding: 1rem;
-                }
-            }
-        }
-    }
-
-    &__table-amount {
-        width: 25%;
-        min-width: 90px;
-    }
-
-    &__table-status {
-        min-width: 160px;
-        width: 25%;
-    }
-
-    &__table-date {
-        min-width: 180px;
-        width: 25%;
-    }
-
-    &__table-update {
-        min-width: 160px;
-        width: 25%;
-    }
-}
diff --git a/opentech/static_src/src/sass/apply/components/_projects-table.scss b/opentech/static_src/src/sass/apply/components/_projects-table.scss
new file mode 100644
index 0000000000000000000000000000000000000000..c4ba256df7f6bdd460bb540d81328a6c93df87ef
--- /dev/null
+++ b/opentech/static_src/src/sass/apply/components/_projects-table.scss
@@ -0,0 +1,10 @@
+.projects-table {
+    .reporting {
+        .icon {
+            margin-right: 0.3rem;
+            width: 25px;
+            height: 25px;
+            fill: $color--tomato;
+        }
+    }
+}
diff --git a/opentech/static_src/src/sass/apply/components/_all-rounds-table.scss b/opentech/static_src/src/sass/apply/components/_responsive-table.scss
similarity index 94%
rename from opentech/static_src/src/sass/apply/components/_all-rounds-table.scss
rename to opentech/static_src/src/sass/apply/components/_responsive-table.scss
index 3be90e1d1aeed7bea486547e45a16653f0d757c5..ea14a560a1783bd3313c4b242a2ff86acbbf919a 100644
--- a/opentech/static_src/src/sass/apply/components/_all-rounds-table.scss
+++ b/opentech/static_src/src/sass/apply/components/_responsive-table.scss
@@ -1,4 +1,4 @@
-.all-rounds-table {
+.responsive-table {
     @include table-ordering-styles;
 
     thead {
diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss
index d2cd0d97d0c70c71b9e56bbda14aba977e6c7fb4..cbba6b86e8c3fccff9dfdc25cd02e8b216e030d5 100644
--- a/opentech/static_src/src/sass/apply/main.scss
+++ b/opentech/static_src/src/sass/apply/main.scss
@@ -8,8 +8,8 @@
 @import 'base/typography';
 
 // Components
+@import 'components/alert';
 @import 'components/all-submissions-table';
-@import 'components/all-rounds-table';
 @import 'components/admin-bar';
 @import 'components/actions-bar';
 @import 'components/card';
@@ -38,7 +38,9 @@
 @import 'components/nav';
 @import 'components/pagination';
 @import 'components/profile';
+@import 'components/projects-table';
 @import 'components/related-sidebar';
+@import 'components/responsive-table';
 @import 'components/reviewer-dash-box';
 @import 'components/reviews-list';
 @import 'components/reviews-summary';
@@ -58,7 +60,7 @@
 @import 'components/stat-block';
 @import 'components/docs-block';
 @import 'components/funding-block';
-@import 'components/payment-block';
+@import 'components/data-block';
 @import 'components/invoice-block';
 
 // Layout
diff --git a/requirements-dev.txt b/requirements-dev.txt
index bd3c6039bb60213e542d2d10598a6ecfc7cf9209..2af17c454bfd0a30349bd304b59575a3b5413044 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,10 +1,10 @@
 -r requirements.txt
 
 django-debug-toolbar==2.0
-factory_boy==2.9.2
+factory_boy==2.12
 Faker==1.0.8  # Pinning to avoid API changes to pydecimal in 0.x - could go higher in future
 flake8==3.7.9
 responses==0.10.6
 stellar==0.4.5
-wagtail-factories==1.1.0
+wagtail-factories==2.0.0
 Werkzeug==0.15.3