From 1f52931f8bdb2b4d609fed8cfb821c88c1d9750f Mon Sep 17 00:00:00 2001
From: Todd Dembrey <todd.dembrey@torchbox.com>
Date: Fri, 1 Nov 2019 11:01:06 +0000
Subject: [PATCH] Feature/gh 1617 submit a report (#1629)

* Add basic reporting to the project
* Add file uploading to the reporting
* Add activity for submit report
---
 opentech/apply/activity/messaging.py          | 26 ++---
 .../migrations/0049_add_submit_report.py      | 18 ++++
 opentech/apply/activity/options.py            |  1 +
 .../email/payment_request_status_updated.html |  2 +-
 .../email/payment_request_updated.html        |  2 +-
 .../messages/email/report_submitted.html      |  8 ++
 opentech/apply/determinations/forms.py        | 14 +--
 opentech/apply/projects/forms.py              | 34 ++++++-
 .../migrations/0025_add_report_models.py      | 64 ++++++++++++
 .../0026_data_contract_approved_date.py       | 20 ++++
 opentech/apply/projects/models.py             | 98 ++++++++++++++++++-
 .../application_projects/project_detail.html  | 13 +++
 .../application_projects/report_form.html     | 39 ++++++++
 opentech/apply/projects/tests/factories.py    | 30 ++++++
 opentech/apply/projects/tests/test_models.py  | 77 ++++++++++++++-
 opentech/apply/projects/tests/test_views.py   | 71 +++++++++++++-
 opentech/apply/projects/urls.py               |  8 ++
 opentech/apply/projects/views/__init__.py     |  1 +
 opentech/apply/projects/views/project.py      |  6 +-
 opentech/apply/projects/views/report.py       | 81 +++++++++++++++
 opentech/apply/utils/fields.py                | 11 +++
 .../src/sass/apply/components/_admin-bar.scss | 14 +++
 22 files changed, 605 insertions(+), 33 deletions(-)
 create mode 100644 opentech/apply/activity/migrations/0049_add_submit_report.py
 create mode 100644 opentech/apply/activity/templates/messages/email/report_submitted.html
 create mode 100644 opentech/apply/projects/migrations/0025_add_report_models.py
 create mode 100644 opentech/apply/projects/migrations/0026_data_contract_approved_date.py
 create mode 100644 opentech/apply/projects/templates/application_projects/report_form.html
 create mode 100644 opentech/apply/projects/views/report.py
 create mode 100644 opentech/apply/utils/fields.py

diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index 57a31e798..c9eabcb59 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -64,6 +64,7 @@ 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',
 }
 
 
@@ -134,14 +135,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 +239,7 @@ 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',
     }
 
     def recipients(self, message_type, **kwargs):
@@ -405,6 +407,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 +634,9 @@ 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',
     }
 
     def get_subject(self, message_type, source):
@@ -674,15 +678,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 +703,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 +726,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):
diff --git a/opentech/apply/activity/migrations/0049_add_submit_report.py b/opentech/apply/activity/migrations/0049_add_submit_report.py
new file mode 100644
index 000000000..61ec2c4f3
--- /dev/null
+++ b/opentech/apply/activity/migrations/0049_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', '0048_add_project_transition'),
+    ]
+
+    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/options.py b/opentech/apply/activity/options.py
index 7d2f9bb58..e65b5f484 100644
--- a/opentech/apply/activity/options.py
+++ b/opentech/apply/activity/options.py
@@ -42,6 +42,7 @@ 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'
 
     @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 c00b187bf..e35b4d1f7 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 368b14344..a1af1fa8e 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_submitted.html b/opentech/apply/activity/templates/messages/email/report_submitted.html
new file mode 100644
index 000000000..eb5296424
--- /dev/null
+++ b/opentech/apply/activity/templates/messages/email/report_submitted.html
@@ -0,0 +1,8 @@
+{% extends "messages/email/applicant_base.html" %}
+
+{% block content %}
+An {{ ORG_SHORT_NAME }} staff member has submitted a report for {{ source.title }} for period ending {{ report.end_date }}.
+
+Title: {{ source.title }}
+Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
+{% endblock %}
diff --git a/opentech/apply/determinations/forms.py b/opentech/apply/determinations/forms.py
index 12a3417a0..4a94b7b24 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/projects/forms.py b/opentech/apply/projects/forms.py
index 5dc566f88..e4ae81e4f 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -5,11 +5,13 @@ from django.contrib.auth import get_user_model
 from django.core.files.base import ContentFile
 from django.db import transaction
 from django.db.models import Q
+from django.utils import timezone
 
 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 +26,10 @@ from .models import (
     PacketFile,
     PaymentReceipt,
     PaymentRequest,
-    Project
+    Project,
+    Report,
+    ReportVersion,
+    ReportPrivateFiles,
 )
 
 
@@ -369,3 +374,30 @@ class UpdateProjectLeadForm(forms.ModelForm):
         lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id)
                                                   .filter(qwargs)
                                                   .distinct())
+
+
+class ReportEditForm(forms.ModelForm):
+    content = RichTextField(required=True)
+    files = MultiFileField(required=False)
+
+    class Meta:
+        model = Report
+        fields = ['public']
+
+    def save(self, commit=True):
+        instance = super().save(commit)
+
+        version = ReportVersion.objects.create(
+            report=instance,
+            content=self.cleaned_data['content'],
+            submitted=timezone.now(),
+        )
+
+        files = self.cleaned_data['files']
+        if files:
+            ReportPrivateFiles.objects.bulk_create(
+                ReportPrivateFiles(report=version, file=file)
+                for file in files
+            )
+
+        return instance
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 000000000..8a351cc56
--- /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.document_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 000000000..92ba2db6c
--- /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/models.py b/opentech/apply/projects/models.py
index 7e2739bd1..eb812096d 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -4,7 +4,7 @@ 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.postgres.fields import JSONField
@@ -79,6 +79,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()
 
@@ -384,6 +385,15 @@ 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()
+
     def paid_value(self):
         return self.payment_requests.paid_value()
 
@@ -527,3 +537,89 @@ 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 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.project.reports.filter(
+            end_date__lt=today,
+        ).first()
+
+        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 schedule_date > today:
+                # Schedule changed since project inception
+                next_due_date = schedule_date
+            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,
+            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 Report(models.Model):
+    public = models.BooleanField(default=True)
+    end_date = models.DateField()
+    project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports")
+
+    class Meta:
+        ordering = ('-end_date',)
+
+    @property
+    def past_due(self):
+        return timezone.now().date() > self.end_date
+
+
+class ReportVersion(models.Model):
+    report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions")
+    submitted = models.DateTimeField()
+    content = models.TextField()
+
+
+class ReportPrivateFiles(models.Model):
+    report = models.ForeignKey("ReportVersion", on_delete=models.CASCADE, related_name="files")
+    document = models.FileField(upload_to=document_path, storage=PrivateStorage())
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
index de8cabd35..62f722cd4 100644
--- a/opentech/apply/projects/templates/application_projects/project_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -131,6 +131,19 @@
                 </div>
                 {% endif %}
 
+                {% if object.is_in_progress %}
+                    {% with next_report=object.report_config.current_due_report %}
+                    {% if next_report %}
+                    <a
+                        class="button button--primary"
+                        href="{% url "apply:projects:reports:edit" pk=next_report.pk %}"
+                    >
+                        Add Report
+                    </a>
+                    {% endif %}
+                    {% endwith %}
+                {% endif %}
+
                 {% if not object.is_in_progress %}
                 {% include "application_projects/includes/supporting_documents.html" %}
                 {% endif %}
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 000000000..4095ba8db
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/report_form.html
@@ -0,0 +1,39 @@
+{% extends "base-apply.html" %}
+{% load static %}
+
+{% block title %}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">Report for the period ending {{ report.end_date }}</h2>
+        <h5 class="heading heading--no-margin">{{ object.project.title }}</h5>
+    </div>
+</div>
+
+{% include "forms/includes/form_errors.html" with form=form %}
+
+<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar">
+    <div class="wrapper--sidebar--inner">
+        <form class="form" action="" method="post" enctype="multipart/form-data">
+            {% csrf_token %}
+            {{ form.media }}
+
+            {% for field in form %}
+                {% if field.field %}
+                    {% include "forms/includes/field.html" %}
+                {% else %}
+                    {{ field }}
+                {% endif %}
+            {% endfor %}
+            <input class="button button--submit button--top-space button--primary" type="submit" name="save" value="Submit" />
+        </form>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="{% static 'js/apply/list-input-files.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index 23a535ed8..f2c96b46a 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -3,6 +3,7 @@ import json
 
 import factory
 import pytz
+from dateutil.relativedelta import relativedelta
 from django.utils import timezone
 
 from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory
@@ -14,6 +15,8 @@ from opentech.apply.projects.models import (
     PaymentRequest,
     Project,
     ProjectApprovalForm,
+    Report,
+    ReportConfig,
 )
 from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory
 from opentech.apply.users.tests.factories import StaffFactory, UserFactory
@@ -98,6 +101,8 @@ class ProjectFactory(factory.DjangoModelFactory):
 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 +142,28 @@ class PaymentReceiptFactory(factory.DjangoModelFactory):
 
     class Meta:
         model = PaymentReceipt
+
+
+class ReportConfigFactory(factory.DjangoModelFactory):
+    project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory")
+
+    class Meta:
+        model = ReportConfig
+        django_get_or_create = ('project',)
+
+
+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))
+        )
+
+
+class ApprovedProjectFactory(ProjectFactory):
+    contract = factory.RelatedFactory(ContractFactory, 'project')
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index a467f9f1d..4de065a1f 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,72 @@ 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 TestReportConfigCalculations(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()
+        self.assertEqual(Report.objects.count(), 1)
+        self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1))
+
+    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))
+
+        report = config.current_due_report()
+        self.assertEqual(Report.objects.count(), 2)
+        self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1))
+
+    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))
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index 8d84549be..a572392f3 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -33,7 +33,8 @@ from .factories import (
     PacketFileFactory,
     PaymentReceiptFactory,
     PaymentRequestFactory,
-    ProjectFactory
+    ProjectFactory,
+    ReportFactory,
 )
 
 
@@ -1234,3 +1235,71 @@ 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, {'content': 'Some text', 'public': True})
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().content, 'Some text')
+
+    def test_change_privacy(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'content': 'Some text', 'public': False})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertFalse(report.public)
+
+
+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, {'content': 'Some text', 'public': True})
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertEqual(report.versions.first().content, 'Some text')
+
+    def test_change_privacy_own(self):
+        report = ReportFactory(project__user=self.user)
+        response = self.post_page(report, {'content': 'Some text', 'public': False})
+        report.refresh_from_db()
+        self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url()))
+        self.assertFalse(report.public)
+
+    def test_cant_submit_other_report(self):
+        report = ReportFactory()
+        response = self.post_page(report, {'content': 'Some text', 'public': True})
+        self.assertEqual(response.status_code, 403)
diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py
index 3a0e838a8..eb512ba0e 100644
--- a/opentech/apply/projects/urls.py
+++ b/opentech/apply/projects/urls.py
@@ -14,6 +14,8 @@ from .views import (
     ProjectListView,
     ProjectOverviewView,
     ProjectPrivateMediaView,
+    ReportPrivateMedia,
+    ReportUpdateView,
 )
 
 app_name = 'projects'
@@ -39,4 +41,10 @@ urlpatterns = [
             path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"),
         ])),
     ], 'payments'))),
+    path('reports/', include(([
+        path('<int:pk>/', include([
+            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 fa45c29c1..940f89395 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 88cf33d6b..ad6481056 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
@@ -67,6 +68,8 @@ from ..tables import (
     ProjectsListTable
 )
 
+from .report import ReportingMixin
+
 
 # APPROVAL VIEWS
 
@@ -319,6 +322,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 +401,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
diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py
new file mode 100644
index 000000000..28115a366
--- /dev/null
+++ b/opentech/apply/projects/views/report.py
@@ -0,0 +1,81 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.utils.decorators import method_decorator
+from django.core.exceptions import PermissionDenied
+from django.views.generic import (
+    UpdateView
+)
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.utils.storage import PrivateMediaView
+
+from ..models import Report, ReportConfig
+from ..forms import ReportEditForm
+
+
+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:
+    model = Report
+
+    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 ReportUpdateView(ReportAccessMixin, UpdateView):
+    form_class = ReportEditForm
+
+    def get_success_url(self):
+        return self.object.project.get_absolute_url()
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        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)
+
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        file_pk = kwargs.get('file_pk')
+        document = get_object_or_404(self.report.files, pk=file_pk)
+        return 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
diff --git a/opentech/apply/utils/fields.py b/opentech/apply/utils/fields.py
new file mode 100644
index 000000000..952f37088
--- /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/sass/apply/components/_admin-bar.scss b/opentech/static_src/src/sass/apply/components/_admin-bar.scss
index ef371d1a7..4004a9d68 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);
+        }
+    }
+
 }
-- 
GitLab