From 9ec743879325e6565242c5f679edd1337c481745 Mon Sep 17 00:00:00 2001
From: Todd Dembrey <todd.dembrey@torchbox.com>
Date: Wed, 11 Sep 2019 15:39:23 +0100
Subject: [PATCH] Feature/post demo 2 fixes (#1501)

* Make the choose file link intelligent

* Update the document chooser to user the model form and update tests

* Update the messaging to be more in line with other messages

* Make sure we tick the documents when all uploaded

* Tidy up the visual components (contract/links) on the detail page

* Tidy up how forms are contributed to the parent class

* Improve the delegated form base behaviour

* Refactor to remove the payments mixin

* Give the detail and edit views of the payment request their own view

* Remove the download link and change to a view

* Refactor the views to be better sorted

* Improve the interaction experience for payment requests and add activity

* Improve look of payment table to be a bit more bulked out

* Allow passing a path to the slack notification system

User.get_absolute_url was pointing at the wagtail admin which would expose the
admin urls to users on the frontend. The admin view is not necessarily the
"canonical" view for the user so should not be relied upon in the long run.

By allowing passing of a url to the slack notifier we allow the emitter to point
to the destination regardless of the more front end focused model method.
---
 opentech/apply/activity/messaging.py          |  30 +-
 .../activity/include/listing_base.html        |   4 +-
 .../apply/activity/tests/test_messaging.py    |   2 +-
 opentech/apply/activity/views.py              |  11 +-
 opentech/apply/funds/forms.py                 |   4 -
 .../funds/includes/delegated_form_base.html   |   5 +-
 opentech/apply/projects/files.py              |   2 +-
 opentech/apply/projects/forms.py              |  81 ++-
 opentech/apply/projects/models.py             |  70 ++-
 .../includes/funding_block.html               |  10 +-
 .../includes/payment_requests.html            |  48 +-
 .../includes/paymentrequest_admin_detail.html |  28 +
 .../includes/supporting_documents.html        |  25 +-
 .../paymentrequest_admin_detail.html          |  36 ++
 .../paymentrequest_detail.html                |  62 +++
 .../paymentrequest_form.html                  |  36 ++
 .../application_projects/project_detail.html  |  23 +-
 .../templatetags/payment_request_tools.py     |  36 +-
 opentech/apply/projects/tests/factories.py    |   5 +
 opentech/apply/projects/tests/test_forms.py   |  59 ++-
 opentech/apply/projects/tests/test_models.py  |  38 +-
 .../apply/projects/tests/test_templatetags.py |  22 +-
 opentech/apply/projects/tests/test_views.py   | 376 ++++++++++++--
 opentech/apply/projects/urls.py               |  19 +-
 opentech/apply/projects/views/__init__.py     |   2 +
 opentech/apply/projects/views/payment.py      | 202 ++++++++
 .../projects/{views.py => views/project.py}   | 489 ++++++------------
 opentech/apply/users/decorators.py            |   8 +
 opentech/apply/users/models.py                |   4 -
 opentech/apply/users/wagtail_hooks.py         |   3 +
 opentech/apply/utils/notifications.py         |  17 +-
 opentech/apply/utils/templatetags/__init__.py |   0
 .../apply/utils/templatetags/apply_tags.py    |   9 +
 opentech/apply/utils/testing/tests.py         |  12 +-
 opentech/apply/utils/views.py                 |  69 ++-
 .../sass/apply/components/_docs-block.scss    |   6 +
 .../sass/apply/components/_payment-block.scss |   6 +-
 37 files changed, 1248 insertions(+), 611 deletions(-)
 create mode 100644 opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html
 create mode 100644 opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html
 create mode 100644 opentech/apply/projects/templates/application_projects/paymentrequest_detail.html
 create mode 100644 opentech/apply/projects/templates/application_projects/paymentrequest_form.html
 create mode 100644 opentech/apply/projects/views/__init__.py
 create mode 100644 opentech/apply/projects/views/payment.py
 rename opentech/apply/projects/{views.py => views/project.py} (62%)
 create mode 100644 opentech/apply/utils/templatetags/__init__.py
 create mode 100644 opentech/apply/utils/templatetags/apply_tags.py

diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py
index e3a6a233b..07bcc73d4 100644
--- a/opentech/apply/activity/messaging.py
+++ b/opentech/apply/activity/messaging.py
@@ -18,7 +18,7 @@ User = get_user_model()
 
 
 def link_to(target, request):
-    if target:
+    if target and hasattr(target, 'get_absolute_url'):
         return request.scheme + '://' + request.get_host() + target.get_absolute_url()
 
 
@@ -218,13 +218,15 @@ class ActivityAdapter(AdapterBase):
         MESSAGES.OPENED_SEALED: 'Opened the submission while still sealed',
         MESSAGES.SCREENING: 'Screening status from {old_status} to {source.screening_status}',
         MESSAGES.REVIEW_OPINION: '{user} {opinion.opinion_display}s with {opinion.review.author}''s review of {source}',
-        MESSAGES.CREATED_PROJECT: '{user} has created Project',
-        MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead} by {user}',
-        MESSAGES.SEND_FOR_APPROVAL: '{user} has requested approval',
-        MESSAGES.APPROVE_PROJECT: '{user} has approved',
-        MESSAGES.REQUEST_PROJECT_CHANGE: '{user} has requested changes to for acceptance: "{comment}"',
-        MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for {source.title}',
-        MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for {source.title}'
+        MESSAGES.CREATED_PROJECT: 'Created',
+        MESSAGES.UPDATE_PROJECT_LEAD: 'Lead changed from {old_lead} to {source.lead}',
+        MESSAGES.SEND_FOR_APPROVAL: 'Requested approval',
+        MESSAGES.APPROVE_PROJECT: 'Approved',
+        MESSAGES.REQUEST_PROJECT_CHANGE: 'Requested changes for acceptance: "{comment}"',
+        MESSAGES.UPLOAD_CONTRACT: 'Uploaded a contract',
+        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',
     }
 
     def recipients(self, message_type, **kwargs):
@@ -342,9 +344,10 @@ class ActivityAdapter(AdapterBase):
             except KeyError:
                 pass
 
-        # TODO resolve how related objects work with submission/project
-        has_correct_fields = all(hasattr(related, attr) for attr in ['author', 'submission', 'get_absolute_url'])
-        if has_correct_fields and isinstance(related, models.Model):
+        has_correct_fields = all(hasattr(related, attr) for attr in ['get_absolute_url'])
+        isnt_source = source != related
+        is_model = isinstance(related, models.Model)
+        if has_correct_fields and isnt_source and is_model:
             related_object = related
         else:
             related_object = None
@@ -394,7 +397,7 @@ class SlackAdapter(AdapterBase):
         MESSAGES.UPLOAD_CONTRACT: '{user} has uploaded a contract for <{link}|{source.title}>.',
         MESSAGES.APPROVE_CONTRACT: '{user} has approved contract for <{link}|{source.title}>.',
         MESSAGES.REQUEST_PAYMENT: '{user} has requested payment for <{link}|{source.title}>.',
-        MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has updated status for payment request <{link}|{source.title}>.',
+        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}>.',
     }
@@ -414,13 +417,16 @@ class SlackAdapter(AdapterBase):
         source = kwargs['source']
         sources = kwargs['sources']
         request = kwargs['request']
+        related = kwargs['related']
         link = link_to(source, request)
+        link_related = link_to(related, request)
         links = {
             source.id: link_to(source, request)
             for source in sources
         }
         return {
             'link': link,
+            'link_related': link_related,
             'links': links,
         }
 
diff --git a/opentech/apply/activity/templates/activity/include/listing_base.html b/opentech/apply/activity/templates/activity/include/listing_base.html
index bdc55edf7..5dce52086 100644
--- a/opentech/apply/activity/templates/activity/include/listing_base.html
+++ b/opentech/apply/activity/templates/activity/include/listing_base.html
@@ -1,4 +1,4 @@
-{% load activity_tags bleach_tags markdown_tags submission_tags %}
+{% load activity_tags bleach_tags markdown_tags submission_tags apply_tags %}
 <div class="feed__item feed__item--{{ activity.type }}">
     <div class="feed__pre-content">
         <p class="feed__label feed__label--{{ activity.type }}">{{ activity.type|capfirst }}</p>
@@ -49,7 +49,7 @@
                 {% with url=activity.related_object.get_absolute_url %}
                     {% if url %}
                     <a href="{{ url }}" class="feed__related-item">
-                        {{ activity.related_object }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
+                        {{ activity.related_object|model_verbose_name }} <svg><use xlink:href="#arrow-head-pixels--solid"></use></svg>
                     </a>
                     {% endif %}
                 {% endwith %}
diff --git a/opentech/apply/activity/tests/test_messaging.py b/opentech/apply/activity/tests/test_messaging.py
index 4341efeb1..08cb17eeb 100644
--- a/opentech/apply/activity/tests/test_messaging.py
+++ b/opentech/apply/activity/tests/test_messaging.py
@@ -607,7 +607,7 @@ class TestAdaptersForProject(AdapterMixin, TestCase):
         )
         self.assertEqual(Activity.objects.count(), 1)
         activity = Activity.objects.first()
-        self.assertEqual(None, activity.related_object)
+        self.assertEqual(project.submission, activity.related_object)
 
     @override_settings(
         SLACK_DESTINATION_URL=target_url,
diff --git a/opentech/apply/activity/views.py b/opentech/apply/activity/views.py
index c0edaf6b7..9b7c4bb43 100644
--- a/opentech/apply/activity/views.py
+++ b/opentech/apply/activity/views.py
@@ -43,12 +43,12 @@ class ActivityContextMixin:
             'actions': Activity.actions.filter(**query).select_related(
                 'user',
             ).prefetch_related(
-                'related_object__submission',
+                'related_object',
             ).visible_to(self.request.user),
             'comments': Activity.comments.filter(**query).select_related(
                 'user',
             ).prefetch_related(
-                'related_object__submission',
+                'related_object',
             ).visible_to(self.request.user),
         }
         return super().get_context_data(**extra, **kwargs)
@@ -77,7 +77,8 @@ class CommentFormView(DelegatedViewMixin, CreateView):
     def get_success_url(self):
         return self.object.source.get_absolute_url() + '#communications'
 
-    @classmethod
-    def contribute_form(cls, instance, user):
+    def get_form_kwargs(self):
         # We dont want to pass the submission as the instance
-        return super().contribute_form(instance=None, user=user)
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        return kwargs
diff --git a/opentech/apply/funds/forms.py b/opentech/apply/funds/forms.py
index 91911a985..74ac4a435 100644
--- a/opentech/apply/funds/forms.py
+++ b/opentech/apply/funds/forms.py
@@ -105,10 +105,6 @@ class BatchUpdateSubmissionLeadForm(forms.Form):
 
     def save(self):
         new_lead = self.cleaned_data['lead']
-        import logging
-        logger = logging.getLogger('opentech')
-        logger.debug(new_lead)
-        logger.debug(new_lead.id)
         submissions = self.cleaned_data['submissions']
 
         for submission in submissions:
diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
index f33bc3d89..18292b3d8 100644
--- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
+++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html
@@ -9,7 +9,10 @@
 
     {{ form.media }}
 
-    {% for field in form %}
+    {% for hidden in form.hidden_fields %}
+        {{ hidden }}
+    {% endfor %}
+    {% for field in form.visible_fields %}
         {% if field.field %}
             {% include "forms/includes/field.html" %}
         {% else %}
diff --git a/opentech/apply/projects/files.py b/opentech/apply/projects/files.py
index 57c4a5eb1..2195d989e 100644
--- a/opentech/apply/projects/files.py
+++ b/opentech/apply/projects/files.py
@@ -25,4 +25,4 @@ def get_files(project):
     file_field_names = project.submission.file_field_ids
     file_fields = (project.submission.data(field) for field in file_field_names)
 
-    yield from flatten(file_fields)
+    return list(flatten(file_fields))
diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py
index f10819b45..aa792f959 100644
--- a/opentech/apply/projects/forms.py
+++ b/opentech/apply/projects/forms.py
@@ -12,7 +12,6 @@ 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 .files import get_files
 from .models import (
     CHANGES_REQUESTED,
     COMMITTED,
@@ -23,7 +22,6 @@ from .models import (
     UNDER_REVIEW,
     Approval,
     Contract,
-    DocumentCategory,
     PacketFile,
     PaymentReceipt,
     PaymentRequest,
@@ -69,15 +67,13 @@ class ChangePaymentRequestStatusForm(forms.ModelForm):
     def __init__(self, instance, *args, **kwargs):
         super().__init__(instance=instance, *args, **kwargs)
 
-        self.instance = instance
-
         status_field = self.fields['status']
 
-        if instance.status == SUBMITTED:
+        if self.instance.status == SUBMITTED:
             wanted = [CHANGES_REQUESTED, UNDER_REVIEW, DECLINED]
-        elif instance.status == CHANGES_REQUESTED:
+        elif self.instance.status == CHANGES_REQUESTED:
             wanted = [DECLINED]
-        elif instance.status == UNDER_REVIEW:
+        elif self.instance.status == UNDER_REVIEW:
             wanted = [PAID]
         else:
             wanted = []
@@ -89,8 +85,6 @@ class CreateProjectForm(forms.Form):
     submission = forms.ModelChoiceField(
         queryset=ApplicationSubmission.objects.filter(project__isnull=True),
         widget=forms.HiddenInput(),
-        label='',
-        required=False,
     )
 
     def __init__(self, instance=None, user=None, *args, **kwargs):
@@ -108,8 +102,6 @@ class CreateApprovalForm(forms.ModelForm):
     by = forms.ModelChoiceField(
         queryset=User.objects.approvers(),
         widget=forms.HiddenInput(),
-        label='',
-        required=False,
     )
 
     class Meta:
@@ -117,9 +109,14 @@ class CreateApprovalForm(forms.ModelForm):
         fields = ('by',)
 
     def __init__(self, user=None, *args, **kwargs):
-        initial = kwargs.pop('initial', {})
-        initial.update(by=user)
-        super().__init__(*args, initial=initial, **kwargs)
+        self.user = user
+        super().__init__(*args, **kwargs)
+
+    def clean_by(self):
+        by = self.cleaned_data['by']
+        if by != self.user:
+            raise forms.ValidationError('Cannot approve for a different user')
+        return by
 
 
 class EditPaymentRequestForm(forms.ModelForm):
@@ -243,41 +240,34 @@ class RequestPaymentForm(forms.ModelForm):
         return request
 
 
-class SelectDocumentForm(forms.Form):
-    category = forms.ModelChoiceField(queryset=DocumentCategory.objects.all())
-    files = forms.MultipleChoiceField()
+class SelectDocumentForm(forms.ModelForm):
+    document = forms.ChoiceField()
 
-    name = 'select_document_form'
+    class Meta:
+        model = PacketFile
+        fields = ['category', 'document']
 
-    def __init__(self, existing_files, project, *args, **kwargs):
+    def __init__(self, existing_files, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        self.project = project
+        self.files = existing_files
+
+        choices = [(f.url, f.filename) for f in self.files]
 
-        choices = []
-        if existing_files is not None:
-            choices = [(f.url, f.filename) for f in existing_files]
+        self.fields['document'].choices = choices
 
-        self.fields['files'].choices = choices
+    def clean_document(self):
+        file_url = self.cleaned_data['document']
+        for file in self.files:
+            if file.url == file_url:
+                new_file = ContentFile(file.read())
+                new_file.name = file.filename
+                return new_file
+        raise forms.ValidationError("File not found on submission")
 
     @transaction.atomic()
     def save(self, *args, **kwargs):
-        category = self.cleaned_data['category']
-        urls = self.cleaned_data['files']
-
-        files = get_files(self.project)
-        files = (f for f in files if f.url in urls)
-
-        for f in files:
-            new_file = ContentFile(f.read())
-            new_file.name = f.filename
-
-            PacketFile.objects.create(
-                category=category,
-                project=self.project,
-                title=f.filename,
-                document=new_file,
-            )
+        return super().save(*args, **kwargs)
 
 
 class SetPendingForm(forms.ModelForm):
@@ -305,15 +295,14 @@ class SetPendingForm(forms.ModelForm):
 
 class UploadContractForm(forms.ModelForm):
     class Meta:
-        fields = ['file', 'is_signed']
+        fields = ['file']
         model = Contract
 
-    def __init__(self, user=None, instance=None, *args, **kwargs):
-        super().__init__(*args, **kwargs)
 
-        if not user.is_staff:
-            self.fields['is_signed'].widget = forms.HiddenInput()
-            self.fields['is_signed'].default = True
+class StaffUploadContractForm(forms.ModelForm):
+    class Meta:
+        fields = ['file', 'is_signed']
+        model = Contract
 
 
 class UploadDocumentForm(forms.ModelForm):
diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py
index d788876bd..9370c0c49 100644
--- a/opentech/apply/projects/models.py
+++ b/opentech/apply/projects/models.py
@@ -3,12 +3,17 @@ import decimal
 import json
 import logging
 
+
 from django.conf import settings
 from django.contrib.contenttypes.fields import GenericRelation
 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 Sum, Value as V
+from django.db.models.functions import 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.translation import ugettext as _
@@ -97,6 +102,12 @@ class PacketFile(models.Model):
         return RemoveDocumentForm(instance=self)
 
 
+@receiver(post_delete, sender=PacketFile)
+def delete_packetfile_file(sender, instance, **kwargs):
+    # Remove the file and don't save the base model
+    instance.document.delete(False)
+
+
 class PaymentApproval(models.Model):
     request = models.ForeignKey('PaymentRequest', on_delete=models.CASCADE, related_name="approvals")
     by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_approvals")
@@ -130,6 +141,26 @@ REQUEST_STATUS_CHOICES = [
 ]
 
 
+class PaymentRequestQueryset(models.QuerySet):
+    def in_progress(self):
+        return self.exclude(status__in=[DECLINED, PAID])
+
+    def rejected(self):
+        return self.filter(status=DECLINED)
+
+    def not_rejected(self):
+        return self.exclude(status=DECLINED)
+
+    def total_value(self):
+        return self.aggregate(total=Coalesce(Sum('value'), V(0)))['total']
+
+    def paid_value(self):
+        return self.filter(status=PAID).total_value()
+
+    def unpaid_value(self):
+        return self.filter(status__in=[SUBMITTED, UNDER_REVIEW]).total_value()
+
+
 class PaymentRequest(models.Model):
     project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="payment_requests")
     by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="payment_requests")
@@ -147,18 +178,45 @@ class PaymentRequest(models.Model):
     comment = models.TextField()
     status = models.TextField(choices=REQUEST_STATUS_CHOICES, default=SUBMITTED)
 
+    objects = PaymentRequestQueryset.as_manager()
+
     def __str__(self):
         return f'Payment requested for {self.project}'
 
-    def user_can_delete(self, user):
+    @property
+    def status_display(self):
+        return self.get_status_display()
+
+    def can_user_delete(self, user):
+        if user.is_applicant:
+            if self.status in (SUBMITTED, CHANGES_REQUESTED):
+                return True
+
+        return False
+
+    def can_user_edit(self, user):
+        if user.is_applicant:
+            if self.status in {SUBMITTED, CHANGES_REQUESTED}:
+                return True
+
         if user.is_apply_staff:
-            return False  # Staff can reject
+            if self.status in {SUBMITTED}:
+                return True
+
+        return False
 
-        if self.status not in (SUBMITTED, CHANGES_REQUESTED):
+    def can_user_change_status(self, user):
+        if not user.is_apply_staff:
+            return False  # Users can't change status
+
+        if self.status in {PAID, DECLINED}:
             return False
 
         return True
 
+    def get_absolute_url(self):
+        return reverse('apply:projects:payments:detail', args=[self.project.pk, self.pk])
+
 
 COMMITTED = 'committed'
 CONTRACTING = 'contracting'
@@ -254,6 +312,12 @@ class Project(BaseStreamForm, AccessFormData, models.Model):
             value=submission.form_data.get('value', 0),
         )
 
+    def paid_value(self):
+        return self.payment_requests.paid_value()
+
+    def unpaid_value(self):
+        return self.payment_requests.unpaid_value()
+
     def clean(self):
         if self.proposed_start is None:
             return
diff --git a/opentech/apply/projects/templates/application_projects/includes/funding_block.html b/opentech/apply/projects/templates/application_projects/includes/funding_block.html
index b08db90a3..c5a1459fe 100644
--- a/opentech/apply/projects/templates/application_projects/includes/funding_block.html
+++ b/opentech/apply/projects/templates/application_projects/includes/funding_block.html
@@ -1,4 +1,4 @@
-{% load humanize %}
+{% load humanize payment_request_tools %}
 <ul class="funding-block">
     <li class="funding-block__item">
         <p class="funding-block__title">Fund total</p>
@@ -6,12 +6,12 @@
     </li>
     <li class="funding-block__item">
         <p class="funding-block__title">Total paid</p>
-        <p class="funding-block__standout">${{ payments.totals.paid_absolute|default:0|intcomma }}</p>
-        <p class="funding-block__meta">({{ payments.totals.paid_percentage|default:0 }}%)</p>
+        <p class="funding-block__standout">${{ object.paid_value|intcomma }}</p>
+        <p class="funding-block__meta">({% percentage object.paid_value object.value %}%)</p>
     </li>
     <li class="funding-block__item">
         <p class="funding-block__title">Awaiting payment</p>
-        <p class="funding-block__standout">${{ payments.totals.awaiting_absolute|default:0|intcomma}}</p>
-        <p class="funding-block__meta">({{ payments.totals.awaiting_percentage|default:0 }}%)</p>
+        <p class="funding-block__standout">${{ object.unpaid_value|intcomma}}</p>
+        <p class="funding-block__meta">({% percentage object.unpaid_value object.value %}%)</p>
     </li>
 </ul>
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 62c864470..2ca9c4313 100644
--- a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
+++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html
@@ -16,35 +16,25 @@
             <tr>
                 <th class="payment-block__table-amount">Amount</th>
                 <th class="payment-block__table-status">Status</th>
-                <th class="payment-block__table-docs">Documents</th>
+                <th class="payment-block__table-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 payments.not_rejected %}
+            {% 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">Documents: </span><a href="#">Download</a></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>
-                    {% can_change_status payment_request user as user_can_change_status %}
-                    {% if user_can_change_status %}
-                    <a
-                        data-fancybox
-                        data-src="#change-payment-status-{{ payment_request.pk }}"
-                        class="payment-block__status-link"
-                        href="#">
-                        Change status
-                    </a>
-                    {% endif %}
-
-                    {% user_can_edit payment_request user as user_can_edit_request %}
+                    <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
-                        data-fancybox
-                        data-src="#edit-payment-request-{{ payment_request.pk }}"
                         class="payment-block__status-link"
-                        href="#">
+                        href="{% url "apply:projects:payments:edit" pk=object.pk pr_pk=payment_request.pk %}">
                         Edit
                     </a>
                     {% endif %}
@@ -58,11 +48,15 @@
                     {% endif %}
                 </td>
             </tr>
+            {% empty %}
+            <tr>
+                No active Payment Requests.
+            </tr>
             {% endfor %}
         </tbody>
     </table>
 
-    {% if payments.rejected %}
+    {% 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>
@@ -72,15 +66,15 @@
                 <tr>
                     <th class="payment-block__table-amount">Amount</th>
                     <th class="payment-block__table-status">Status</th>
-                    <th class="payment-block__table-docs">Documents</th>
+                    <th class="payment-block__table-view"></th>
                 </tr>
             </thead>
             <tbody>
-                {% for payment_request in payments.rejected %}
+                {% for payment_request in object.payment_requests.rejected %}
                 <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><span class="payment-block__mobile-label">Documents: </span><a href="#">Download</a></td>
+                    <td><a href="{{ payment_request.get_absolute_url }}">View</a></td>
                 </tr>
                 {% endfor %}
             </tbody>
@@ -99,13 +93,3 @@
     </div>
 </div>
 {% endfor %}
-
-{% for form in edit_payment_request_forms %}
-<div class="modal" id="edit-payment-request-{{ form.instance.pk }}">
-    <h4 class="modal__header-bar">Edit request</h4>
-    <div class="wrapper--outer-space-medium">
-        {% url 'apply:projects:edit-payment-request' pk=object.pk payment_request_id=form.instance.pk as edit_payment_request_action %}
-        {% include 'funds/includes/delegated_form_base.html' with form=form value='Update' action=edit_payment_request_action %}
-    </div>
-</div>
-{% endfor %}
diff --git a/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html
new file mode 100644
index 000000000..d5fd9a177
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/includes/paymentrequest_admin_detail.html
@@ -0,0 +1,28 @@
+{% extends "application-projects/paymentrequest_detail.html" %}
+
+{% block actions %}
+    {{ block.super }}
+    <a
+        data-fancybox
+        data-src="#change-status"
+        class="button button--bottom-space button--primary button--full-width"
+        href="#"
+    >
+        Change Status
+    </a>
+    <div class="modal" id="change-status">
+        <h4 class="modal__header-bar">Change status</h4>
+        <p>Current status: {{ object.get_status_display }}</p>
+        {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%}
+    </div>
+{% endblock %}
+
+{% block extra_css %}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block extra_js %}
+{{ block.super }}
+<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+<script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
index 60646f19c..1b596b336 100644
--- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
+++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html
@@ -45,21 +45,24 @@
 
         <li class="docs-block__row">
             <div class="docs-block__row-inner">
-                <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg>
+                <svg class="icon docs-block__icon{% if not remaining_document_categories %} is-complete{% endif %}">
+                    <use xlink:href="#tick"></use>
+                </svg>
                 <p class="docs-block__title">Supporting documents</p>
             </div>
             {% if editable %}
             <div class="docs-block__row-inner">
-                <a
-                    data-fancybox
-                    data-src="#copy-supporting-doc"
-                    class="docs-block__link"
-                    style="margin-right:20px"
-                    href="#">
-                    Choose file
-                </a>
-            </div>
-            <div class="docs-block__row-inner">
+                {% if select_document_form %}
+                    <a
+                        data-fancybox
+                        data-src="#copy-supporting-doc"
+                        class="docs-block__link"
+                        href="#">
+                        Choose file
+                    </a>
+                {% else %}
+                    <span class="docs-block__link is-disabled" data-tooltip="No files on submission">Choose file</span>
+                {% endif %}
                 <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a>
             </div>
             {% endif %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html
new file mode 100644
index 000000000..edfdd7fc6
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_admin_detail.html
@@ -0,0 +1,36 @@
+{% extends "application_projects/paymentrequest_detail.html" %}
+{% load static payment_request_tools %}
+
+{% block actions %}
+    {{ block.super }}
+    {% can_change_status object user as user_can_change_status %}
+    <a
+        {% if user_can_change_status %}
+            data-fancybox
+            data-src="#change-status"
+        {% else %}
+            data-tooltip="Cannot change from 'Paid' or 'Declined' state"
+        {% endif %}
+        class="button button--bottom-space button--primary button--full-width{% if not user_can_change_status %} button--tooltip-disabled{% endif %}"
+        href="#"
+    >
+        Change Status
+    </a>
+    {% if user_can_change_status %}
+    <div class="modal" id="change-status">
+        <h4 class="modal__header-bar">Change status</h4>
+        <p>Current status: {{ object.get_status_display }}</p>
+        {% include 'funds/includes/delegated_form_base.html' with form=change_payment_status value='Update'%}
+    </div>
+    {% endif %}
+{% endblock %}
+
+{% block extra_css %}
+<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}">
+{% endblock %}
+
+{% block extra_js %}
+{{ block.super }}
+<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script>
+<script src="{% static 'js/apply/fancybox-global.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html
new file mode 100644
index 000000000..8dc4a84bd
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_detail.html
@@ -0,0 +1,62 @@
+{% extends "base-apply.html" %}
+{% load humanize payment_request_tools %}
+
+{% block title %}Payment Request: {{ object.project.title }}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <a class="simplified__projects-link" href="{{ object.project.get_absolute_url }}">
+            Project
+        </a>
+        <h2 class="heading heading--no-margin">Payment Request</h2>
+        <h5 class="heading heading--no-margin">For: {{ object.project.title }}</h5>
+    </div>
+</div>
+
+<div class="wrapper wrapper--sidebar">
+    <div class="wrapper--sidebar--inner">
+        <h5>Status: {{ object.get_status_display }}</h5>
+        <p>Name of Vendor: {{ object.project.contact_legal_name }}</p>
+        <p>Invoice Number: {{ object.pk }}</p>
+        <p>
+            Period of Performance:
+        </p>
+        <p>
+            From: {{ object.date_from.date }}
+        </p>
+        <p>
+            To: {{ object.date_to.date }}
+        </p>
+        <p>
+            Total: ${{ object.value|intcomma }}
+        </p>
+
+        <h3>Invoice</h3>
+        <a href="{% url "apply:projects:payments:invoice" pk=object.project.pk pr_pk=object.pk %}">Download</a>
+        <h5>Reciepts</h5>
+        {% for reciept in object.receipts.all %}
+            <a href="{% url "apply:projects:payments:receipt" pk=object.project.pk pr_pk=object.pk file_pk=reciept.pk %}">Download</a>
+        {% endfor %}
+    </div>
+    <aside class="sidebar">
+        <div class="js-actions-sidebar sidebar__inner sidebar__inner--light-blue sidebar__inner--actions">
+            {% block actions %}
+                {% can_edit object user as user_can_edit_request %}
+                <a
+                    {% if not user_can_edit_request %}
+                        data-tooltip="Only editable when 'Submitted' or you have been requested to make changes"
+                    {% endif %}
+                    class="button button--bottom-space button--primary button--full-width{% if not user_can_edit_request %} button--tooltip-disabled{% endif %}"
+                    href={% if user_can_edit_request %}
+                        "{% url "apply:projects:payments:edit" pk=object.project.pk pr_pk=object.pk %}"
+                    {% else %}
+                        "#"
+                    {% endif %}
+                >
+                    Edit
+                </a>
+            {% endblock %}
+        </div>
+    </aside>
+</div>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/paymentrequest_form.html b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html
new file mode 100644
index 000000000..b282e7ca1
--- /dev/null
+++ b/opentech/apply/projects/templates/application_projects/paymentrequest_form.html
@@ -0,0 +1,36 @@
+{% extends "base-apply.html" %}
+{% load static %}
+
+{% block title %}Edit Payment Request: {{ object.project.title }}{% endblock %}
+{% block content %}
+<div class="admin-bar">
+    <div class="admin-bar__inner">
+        <h2 class="heading heading--no-margin">Editing Payment Request</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="Save" />
+        </form>
+    </div>
+</div>
+{% endblock %}
+
+{% block extra_js %}
+<script src="{% static 'js/apply/list-input-files.js' %}"></script>
+{% endblock %}
diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html
index aad2923a0..ce52bf012 100644
--- a/opentech/apply/projects/templates/application_projects/project_detail.html
+++ b/opentech/apply/projects/templates/application_projects/project_detail.html
@@ -189,10 +189,10 @@
                 <div class="sidebar__inner">
                     <h5>Supporting Information</h5>
 
-                    <p><a href="{{ object.submission.get_absolute_url }}">Proposal</a></p>
+                    <p><a class="link link--bold" href="{{ object.submission.get_absolute_url }}">Proposal</a></p>
 
                     {% if request.user.is_apply_staff %}
-                    <p><a href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p>
+                    <p><a class="link link--bold" href="{% url 'apply:projects:simplified' pk=project.pk %}">Approval form</a></p>
                     {% endif %}
                 </div>
                 {% endif %}
@@ -207,16 +207,19 @@
 
                     {% for contract in contracts %}
                     <p>
-                        <a href="{{ contract.file.url }}">
-                            Uploaded at {{ contract.created_at|date:"j F Y" }}
-                            ({% if contract.is_signed %}Signed{% else %}Unsigned{% endif %})
+                        <a href="{{ contract.file.url }}" class="link link--bold">
+                            {{ contract.created_at|date:"j F Y" }}
                         </a>
+                            {% if contract.approver %}
+                                    - Approved by {{ contract.approver }}
+                            {% else %}
+                                ({% if contract.is_signed %}Signed{% else %}Unsigned{% endif %})
+                            {% endif %}
+
+                        {% if request.user.is_apply_staff and forloop.first %}
+                            {% block approve_contracts %}{% endblock %}
+                        {% endif %}
                     </p>
-
-                    {% if request.user.is_apply_staff and forloop.first %}
-                    {% block approve_contracts %}{% endblock %}
-                    {% endif %}
-
                     {% endfor %}
 
                 </div>
diff --git a/opentech/apply/projects/templatetags/payment_request_tools.py b/opentech/apply/projects/templatetags/payment_request_tools.py
index e0c2b34bf..310e8a0bf 100644
--- a/opentech/apply/projects/templatetags/payment_request_tools.py
+++ b/opentech/apply/projects/templatetags/payment_request_tools.py
@@ -1,34 +1,36 @@
-from django import template
+import decimal
 
-from ..models import CHANGES_REQUESTED, DECLINED, PAID, SUBMITTED
+from django import template
 
 register = template.Library()
 
 
 @register.simple_tag
 def can_change_status(payment_request, user):
-    if not user.is_apply_staff:
-        return False  # Users can't change status
+    return payment_request.can_user_change_status(user)
 
-    if payment_request.status in (PAID, DECLINED):
-        return False
 
-    return True
+@register.simple_tag
+def can_delete(payment_request, user):
+    return payment_request.can_user_delete(user)
 
 
 @register.simple_tag
-def can_delete(payment_request, user):
-    return payment_request.user_can_delete(user)
+def can_edit(payment_request, user):
+    return payment_request.can_user_edit(user)
 
 
 @register.simple_tag
-def user_can_edit(payment_request, user):
-    # staff or applicant can edit when in SUBMITTED
-    if payment_request.status == SUBMITTED:
-        return True
+def percentage(value, total):
+    if not total:
+        return decimal.Decimal(0)
+
+    unrounded_total = (value / total) * 100
 
-    # applicant can edit when in CHANGES_REQUESTED
-    if payment_request.status == CHANGES_REQUESTED and user.is_applicant:
-        return True
+    # round using Decimal since we're dealing with currency
+    rounded_total = unrounded_total.quantize(
+        decimal.Decimal('0.0'),
+        rounding=decimal.ROUND_DOWN,
+    )
 
-    return False
+    return rounded_total
diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py
index 098e85f70..084f7e65d 100644
--- a/opentech/apply/projects/tests/factories.py
+++ b/opentech/apply/projects/tests/factories.py
@@ -89,6 +89,11 @@ class ProjectFactory(factory.DjangoModelFactory):
     class Meta:
         model = Project
 
+    class Params:
+        in_approval = factory.Trait(
+            is_locked=True,
+        )
+
 
 class ContractFactory(factory.DjangoModelFactory):
     approver = factory.SubFactory(StaffFactory)
diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py
index cc917b989..29a1c657e 100644
--- a/opentech/apply/projects/tests/test_forms.py
+++ b/opentech/apply/projects/tests/test_forms.py
@@ -1,9 +1,12 @@
 from io import BytesIO
+from unittest import mock
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase, override_settings
 
-from opentech.apply.users.tests.factories import UserFactory
+from opentech.apply.users.tests.factories import (
+    UserFactory,
+)
 
 from ..files import get_files
 from ..forms import (
@@ -11,10 +14,18 @@ from ..forms import (
     ProjectApprovalForm,
     RequestPaymentForm,
     SelectDocumentForm,
+    StaffUploadContractForm,
+    UploadContractForm,
     filter_choices,
     filter_request_choices
 )
-from ..models import CHANGES_REQUESTED, DECLINED, PAID, SUBMITTED, UNDER_REVIEW
+from ..models import (
+    CHANGES_REQUESTED,
+    DECLINED,
+    PAID,
+    SUBMITTED,
+    UNDER_REVIEW,
+)
 from .factories import (
     DocumentCategoryFactory,
     PaymentRequestFactory,
@@ -28,7 +39,7 @@ class TestChangePaymentRequestStatusForm(TestCase):
         request = PaymentRequestFactory(status=SUBMITTED)
         form = ChangePaymentRequestStatusForm(instance=request)
 
-        expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED, PAID]))
+        expected = set(filter_request_choices([UNDER_REVIEW, CHANGES_REQUESTED, DECLINED]))
         actual = set(form.fields['status'].choices)
 
         self.assertEqual(expected, actual)
@@ -165,20 +176,48 @@ class TestSelectDocumentForm(TestCase):
         files = list(get_files(project))
         self.assertEqual(len(files), 4)
 
-        urls = [files[0].url, files[2].url]
+        url = files[3].url
 
         form = SelectDocumentForm(
             files,
-            project,
-            data={'category': category.id, 'files': urls},
+            data={'category': category.id, 'document': url},
         )
         self.assertTrue(form.is_valid(), form.errors)
 
+        form.instance.project = project
         form.save()
 
         packet_files = project.packet_files.order_by('id')
-        self.assertEqual(len(packet_files), 2)
+        self.assertEqual(len(packet_files), 1)
+
+        self.assertEqual(packet_files.first().document.read(), files[3].read())
+
+
+class TestStaffContractUploadForm(TestCase):
+    mock_file = mock.MagicMock(spec=SimpleUploadedFile)
+    mock_file.read.return_value = b"fake file contents"
+
+    def test_staff_can_upload_unsigned(self):
+        form = StaffUploadContractForm(data={}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertFalse(form.cleaned_data.get('is_signed'))
 
-        first_file, second_file = packet_files
-        self.assertEqual(first_file.document.read(), files[0].read())
-        self.assertEqual(second_file.document.read(), files[2].read())
+    def test_staff_can_upload_signed(self):
+        form = StaffUploadContractForm(data={'is_signed': True}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertTrue(form.cleaned_data.get('is_signed'))
+
+
+class TestContractUploadForm(TestCase):
+    mock_file = mock.MagicMock(spec=SimpleUploadedFile)
+    mock_file.read.return_value = b"fake file contents"
+
+    def test_applicant_cant_upload_unsigned(self):
+        form = UploadContractForm(data={}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertIsNone(form.cleaned_data.get('is_signed'))
+
+    def test_applicant_can_upload_signed(self):
+        form = UploadContractForm(data={'is_signed': True}, files={'file': self.mock_file})
+        self.assertTrue(form.is_valid(), form.errors)
+        self.assertIsNone(form.cleaned_data.get('is_signed'))
diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py
index a03d24b4c..2ac88c4ba 100644
--- a/opentech/apply/projects/tests/test_models.py
+++ b/opentech/apply/projects/tests/test_models.py
@@ -9,7 +9,8 @@ from ..models import (
     PAID,
     SUBMITTED,
     UNDER_REVIEW,
-    Project
+    Project,
+    PaymentRequest,
 )
 from .factories import (
     DocumentCategoryFactory,
@@ -76,58 +77,71 @@ class TestProjectModel(TestCase):
         payment_request = PaymentRequestFactory(status=SUBMITTED)
         staff = StaffFactory()
 
-        self.assertFalse(payment_request.user_can_delete(staff))
+        self.assertFalse(payment_request.can_user_delete(staff))
 
     def test_staff_cant_delete_from_under_review(self):
         payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
         staff = StaffFactory()
 
-        self.assertFalse(payment_request.user_can_delete(staff))
+        self.assertFalse(payment_request.can_user_delete(staff))
 
     def test_staff_cant_delete_from_changes_requested(self):
         payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
         staff = StaffFactory()
 
-        self.assertFalse(payment_request.user_can_delete(staff))
+        self.assertFalse(payment_request.can_user_delete(staff))
 
     def test_staff_cant_delete_from_paid(self):
         payment_request = PaymentRequestFactory(status=PAID)
         staff = StaffFactory()
 
-        self.assertFalse(payment_request.user_can_delete(staff))
+        self.assertFalse(payment_request.can_user_delete(staff))
 
     def test_staff_cant_delete_from_declined(self):
         payment_request = PaymentRequestFactory(status=DECLINED)
         staff = StaffFactory()
 
-        self.assertFalse(payment_request.user_can_delete(staff))
+        self.assertFalse(payment_request.can_user_delete(staff))
 
-    def test_user_can_delete_from_submitted(self):
+    def test_can_user_delete_from_submitted(self):
         payment_request = PaymentRequestFactory(status=SUBMITTED)
         user = ApplicantFactory()
 
-        self.assertTrue(payment_request.user_can_delete(user))
+        self.assertTrue(payment_request.can_user_delete(user))
 
     def test_user_cant_delete_from_under_review(self):
         payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
         user = ApplicantFactory()
 
-        self.assertFalse(payment_request.user_can_delete(user))
+        self.assertFalse(payment_request.can_user_delete(user))
 
     def test_user_can_delete_from_changes_requested(self):
         payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
         user = ApplicantFactory()
 
-        self.assertTrue(payment_request.user_can_delete(user))
+        self.assertTrue(payment_request.can_user_delete(user))
 
     def test_user_cant_delete_from_paid(self):
         payment_request = PaymentRequestFactory(status=PAID)
         user = ApplicantFactory()
 
-        self.assertFalse(payment_request.user_can_delete(user))
+        self.assertFalse(payment_request.can_user_delete(user))
 
     def test_user_cant_delete_from_declined(self):
         payment_request = PaymentRequestFactory(status=DECLINED)
         user = ApplicantFactory()
 
-        self.assertFalse(payment_request.user_can_delete(user))
+        self.assertFalse(payment_request.can_user_delete(user))
+
+
+class TestPaymentRequestsQueryset(TestCase):
+    def test_get_totals(self):
+        PaymentRequestFactory(value=20)
+        PaymentRequestFactory(value=10, status=PAID)
+
+        self.assertEqual(PaymentRequest.objects.paid_value(), 10)
+        self.assertEqual(PaymentRequest.objects.unpaid_value(), 20)
+
+    def test_get_totals_no_value(self):
+        self.assertEqual(PaymentRequest.objects.paid_value(), 0)
+        self.assertEqual(PaymentRequest.objects.unpaid_value(), 0)
diff --git a/opentech/apply/projects/tests/test_templatetags.py b/opentech/apply/projects/tests/test_templatetags.py
index 9cfde17da..a6ce390f9 100644
--- a/opentech/apply/projects/tests/test_templatetags.py
+++ b/opentech/apply/projects/tests/test_templatetags.py
@@ -18,7 +18,7 @@ from ..templatetags.contract_tools import user_can_upload_contract
 from ..templatetags.payment_request_tools import (
     can_change_status,
     can_delete,
-    user_can_edit
+    can_edit
 )
 from .factories import ContractFactory, PaymentRequestFactory, ProjectFactory
 
@@ -217,41 +217,41 @@ class TestPaymentRequestTools(TestCase):
         applicant = ApplicantFactory()
         staff = StaffFactory()
 
-        self.assertTrue(user_can_edit(payment_request, applicant))
-        self.assertTrue(user_can_edit(payment_request, staff))
+        self.assertTrue(can_edit(payment_request, applicant))
+        self.assertTrue(can_edit(payment_request, staff))
 
     def test_applicant_can_edit_in_changes_requested(self):
         payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
         applicant = ApplicantFactory()
 
-        self.assertTrue(user_can_edit(payment_request, applicant))
+        self.assertTrue(can_edit(payment_request, applicant))
 
     def test_staff_cant_edit_in_changes_requested(self):
         payment_request = PaymentRequestFactory(status=CHANGES_REQUESTED)
         staff = StaffFactory()
 
-        self.assertFalse(user_can_edit(payment_request, staff))
+        self.assertFalse(can_edit(payment_request, staff))
 
     def test_applicant_and_staff_cant_edit_in_under_review(self):
         payment_request = PaymentRequestFactory(status=UNDER_REVIEW)
         applicant = ApplicantFactory()
         staff = StaffFactory()
 
-        self.assertFalse(user_can_edit(payment_request, applicant))
-        self.assertFalse(user_can_edit(payment_request, staff))
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
 
     def test_applicant_and_staff_cant_edit_in_paid(self):
         payment_request = PaymentRequestFactory(status=PAID)
         applicant = ApplicantFactory()
         staff = StaffFactory()
 
-        self.assertFalse(user_can_edit(payment_request, applicant))
-        self.assertFalse(user_can_edit(payment_request, staff))
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
 
     def test_applicant_and_staff_cant_edit_in_decline(self):
         payment_request = PaymentRequestFactory(status=DECLINED)
         applicant = ApplicantFactory()
         staff = StaffFactory()
 
-        self.assertFalse(user_can_edit(payment_request, applicant))
-        self.assertFalse(user_can_edit(payment_request, staff))
+        self.assertFalse(can_edit(payment_request, applicant))
+        self.assertFalse(can_edit(payment_request, staff))
diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py
index 5384d1100..374d9352b 100644
--- a/opentech/apply/projects/tests/test_views.py
+++ b/opentech/apply/projects/tests/test_views.py
@@ -18,8 +18,15 @@ from opentech.apply.users.tests.factories import (
 from opentech.apply.utils.testing.tests import BaseViewTestCase
 
 from ..forms import SetPendingForm
-from ..models import CONTRACTING, IN_PROGRESS, PAID
-from ..views import ContractsMixin, PaymentsMixin, ProjectDetailSimplifiedView
+from ..files import get_files
+from ..models import (
+    CHANGES_REQUESTED,
+    COMMITTED,
+    CONTRACTING,
+    IN_PROGRESS,
+    SUBMITTED,
+)
+from ..views import ContractsMixin, ProjectDetailSimplifiedView
 from .factories import (
     ContractFactory,
     DocumentCategoryFactory,
@@ -33,27 +40,38 @@ from .factories import (
 class TestCreateApprovalView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
-    user_factory = StaffFactory
+    user_factory = ApproverFactory
 
     def get_kwargs(self, instance):
         return {'pk': instance.id}
 
     def test_creating_an_approval_happy_path(self):
-        project = ProjectFactory()
+        project = ProjectFactory(in_approval=True)
         self.assertEqual(project.approvals.count(), 0)
 
         response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': self.user.id})
         self.assertEqual(response.status_code, 200)
 
         project.refresh_from_db()
-        approval = project.approvals.first()
-
         self.assertEqual(project.approvals.count(), 1)
         self.assertFalse(project.is_locked)
         self.assertEqual(project.status, 'contracting')
 
+        approval = project.approvals.first()
         self.assertEqual(approval.project_id, project.pk)
 
+    def test_creating_an_approval_other_approver(self):
+        project = ProjectFactory(in_approval=True)
+        self.assertEqual(project.approvals.count(), 0)
+
+        other = self.user_factory()
+        response = self.post_page(project, {'form-submitted-add_approval_form': '', 'by': other.id})
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+        self.assertEqual(project.approvals.count(), 0)
+        self.assertTrue(project.is_locked)
+
 
 class BaseProjectDetailTestCase(BaseViewTestCase):
     url_name = 'funds:projects:{}'
@@ -78,7 +96,7 @@ class TestStaffProjectDetailView(BaseProjectDetailTestCase):
 
 
 class TestUserProjectDetailView(BaseProjectDetailTestCase):
-    user_factory = UserFactory
+    user_factory = ApplicantFactory
 
     def test_doesnt_have_access(self):
         project = ProjectFactory()
@@ -109,6 +127,62 @@ class TestReviewerUserProjectDetailView(BaseProjectDetailTestCase):
         self.assertEqual(response.status_code, 403)
 
 
+class TestStaffProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = StaffFactory
+
+    def test_cant_reject(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 403)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
+class TestApproverProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = ApproverFactory
+
+    def test_can_reject(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertFalse(project.is_locked)
+
+    def test_cant_reject_no_comment(self):
+        project = ProjectFactory(in_approval=True)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': '',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
+class TestUserProjectRejectView(BaseProjectDetailTestCase):
+    user_factory = ApplicantFactory
+
+    def test_cant_reject(self):
+        project = ProjectFactory(in_approval=True, user=self.user)
+        response = self.post_page(project, {
+            'form-submitted-rejection_form': '',
+            'comment': 'needs to change',
+        })
+        self.assertEqual(response.status_code, 200)
+        project = self.refresh(project)
+        self.assertEqual(project.status, COMMITTED)
+        self.assertTrue(project.is_locked)
+
+
 class TestRemoveDocumentView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
@@ -260,6 +334,58 @@ class TestStaffUploadContractView(BaseViewTestCase):
         self.assertTrue(project.contracts.first().is_signed)
 
 
+class TestStaffSelectDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_can_choose(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory()
+
+        files = get_files(project)
+
+        response = self.post_page(project, {
+            'form-submitted-select_document_form': '',
+            'category': category.id,
+            'document': files[0].url,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.packet_files.count(), 1)
+
+
+class TestApplicantSelectDocumentView(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {'pk': instance.id}
+
+    def test_can_choose(self):
+        category = DocumentCategoryFactory()
+        project = ProjectFactory(user=self.user)
+
+        files = get_files(project)
+
+        response = self.post_page(project, {
+            'form-submitted-select_document_form': '',
+            'category': category.id,
+            'document': files[0].url,
+        })
+        self.assertEqual(response.status_code, 200)
+
+        project.refresh_from_db()
+
+        self.assertEqual(project.packet_files.count(), 1)
+
+
 class TestUploadDocumentView(BaseViewTestCase):
     base_view_name = 'detail'
     url_name = 'funds:projects:{}'
@@ -700,31 +826,6 @@ class TestRequestPaymentViewAsStaff(BaseViewTestCase):
         self.assertEqual(project.payment_requests.first().by, self.user)
 
 
-class TestPaymentsMixin(TestCase):
-    def test_get_totals(self):
-        project = ProjectFactory(value=Decimal(100))
-        user = UserFactory()
-
-        PaymentRequestFactory(project=project, by=user, value=20)
-        PaymentRequestFactory(project=project, by=user, value=10, status=PAID)
-
-        values = PaymentsMixin().get_totals(project)
-
-        self.assertEqual(values['awaiting_absolute'], 20)
-        self.assertEqual(values['awaiting_percentage'], 20)
-        self.assertEqual(values['paid_absolute'], 10)
-        self.assertEqual(values['paid_percentage'], 10)
-
-    def test_get_totals_no_value(self):
-        project = ProjectFactory(value=Decimal(0))
-        values = PaymentsMixin().get_totals(project)
-
-        self.assertEqual(values['awaiting_absolute'], 0)
-        self.assertEqual(values['awaiting_percentage'], 0)
-        self.assertEqual(values['paid_absolute'], 0)
-        self.assertEqual(values['paid_percentage'], 0)
-
-
 class TestProjectDetailSimplifiedView(TestCase):
     def test_staff_only(self):
         factory = RequestFactory()
@@ -741,13 +842,58 @@ class TestProjectDetailSimplifiedView(TestCase):
             ProjectDetailSimplifiedView.as_view()(request, pk=project.pk)
 
 
+class TestStaffDetailPaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 200)
+
+    def test_wrong_project_cant(self):
+        other_project = ProjectFactory()
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantDetailPaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 200)
+
+    def test_other_cant(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 403)
+
+
 class TestApplicantEditPaymentRequestView(BaseViewTestCase):
-    base_view_name = 'edit-payment-request'
-    url_name = 'funds:projects:{}'
+    base_view_name = 'edit'
+    url_name = 'funds:projects:payments:{}'
     user_factory = ApplicantFactory
 
     def get_kwargs(self, instance):
-        return {'pk': instance.project.pk, 'payment_request_id': instance.pk}
+        return {'pk': instance.project.pk, 'pr_pk': instance.pk}
 
     def test_editing_payment_request_fires_messaging(self):
         project = ProjectFactory(user=self.user)
@@ -760,7 +906,6 @@ class TestApplicantEditPaymentRequestView(BaseViewTestCase):
         invoice.name = 'invoice.pdf'
 
         response = self.post_page(payment_request, {
-            'form-submitted-edit_request_payment_form': '',
             'value': value + 1,
             'date_from': '2018-08-15',
             'date_to': '2019-08-15',
@@ -780,12 +925,12 @@ class TestApplicantEditPaymentRequestView(BaseViewTestCase):
 
 
 class TestStaffEditPaymentRequestView(BaseViewTestCase):
-    base_view_name = 'edit-payment-request'
-    url_name = 'funds:projects:{}'
+    base_view_name = 'edit'
+    url_name = 'funds:projects:payments:{}'
     user_factory = StaffFactory
 
     def get_kwargs(self, instance):
-        return {'pk': instance.project.pk, 'payment_request_id': instance.pk}
+        return {'pk': instance.project.pk, 'pr_pk': instance.pk}
 
     def test_editing_payment_request_fires_messaging(self):
         project = ProjectFactory()
@@ -798,7 +943,6 @@ class TestStaffEditPaymentRequestView(BaseViewTestCase):
         invoice.name = 'invoice.pdf'
 
         response = self.post_page(payment_request, {
-            'form-submitted-request_payment_form': '',
             'value': value + 1,
             'date_from': '2018-08-15',
             'date_to': '2019-08-15',
@@ -815,3 +959,155 @@ class TestStaffEditPaymentRequestView(BaseViewTestCase):
         self.assertEqual(project.payment_requests.first().pk, payment_request.pk)
 
         self.assertEqual(value + Decimal("1"), payment_request.value)
+
+
+class TestStaffChangePaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory()
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+        })
+        self.assertEqual(response.status_code, 200)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, CHANGES_REQUESTED)
+
+
+class TestApplicantChangePaymentRequestStatus(BaseViewTestCase):
+    base_view_name = 'detail'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+        })
+        self.assertEqual(response.status_code, 200)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, SUBMITTED)
+
+    def test_other_cant(self):
+        payment_request = PaymentRequestFactory()
+        response = self.post_page(payment_request, {
+            'form-submitted-change_payment_status': '',
+            'status': CHANGES_REQUESTED,
+        })
+        self.assertEqual(response.status_code, 403)
+        payment_request.refresh_from_db()
+        self.assertEqual(payment_request.status, SUBMITTED)
+
+
+class TestStaffPaymentRequestInvoicePrivateMedia(BaseViewTestCase):
+    base_view_name = 'invoice'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can_access(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertContains(response, payment_request.invoice.read())
+
+    def test_cant_access_if_project_wrong(self):
+        other_project = ProjectFactory()
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request, url_kwargs={'pk': other_project.pk})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantPaymentRequestInvoicePrivateMedia(BaseViewTestCase):
+    base_view_name = 'invoice'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.project.pk,
+            'pr_pk': instance.pk,
+        }
+
+    def test_can_access_own(self):
+        payment_request = PaymentRequestFactory(project__user=self.user)
+        response = self.get_page(payment_request)
+        self.assertContains(response, payment_request.invoice.read())
+
+    def test_cant_access_other(self):
+        payment_request = PaymentRequestFactory()
+        response = self.get_page(payment_request)
+        self.assertEqual(response.status_code, 403)
+
+
+class TestStaffPaymentRequestReceiptPrivateMedia(BaseViewTestCase):
+    base_view_name = 'receipt'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = StaffFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.payment_request.project.pk,
+            'pr_pk': instance.payment_request.pk,
+            'file_pk': instance.pk,
+        }
+
+    def test_can_access(self):
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt)
+        self.assertContains(response, payment_receipt.file.read())
+
+    def test_cant_access_if_project_wrong(self):
+        other_project = ProjectFactory()
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt, url_kwargs={'pk': other_project.pk})
+        self.assertEqual(response.status_code, 404)
+
+    def test_cant_access_if_request_is_wrong(self):
+        other_request = PaymentRequestFactory()
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt, url_kwargs={'pr_pk': other_request.pk})
+        self.assertEqual(response.status_code, 404)
+
+
+class TestApplicantPaymentRequestReceiptPrivateMedia(BaseViewTestCase):
+    base_view_name = 'receipt'
+    url_name = 'funds:projects:payments:{}'
+    user_factory = ApplicantFactory
+
+    def get_kwargs(self, instance):
+        return {
+            'pk': instance.payment_request.project.pk,
+            'pr_pk': instance.payment_request.pk,
+            'file_pk': instance.pk,
+        }
+
+    def test_can_access_own(self):
+        payment_receipt = PaymentReceiptFactory(payment_request__project__user=self.user)
+        response = self.get_page(payment_receipt)
+        self.assertContains(response, payment_receipt.file.read())
+
+    def test_cant_access_other(self):
+        payment_receipt = PaymentReceiptFactory()
+        response = self.get_page(payment_receipt)
+        self.assertEqual(response.status_code, 403)
diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py
index 8ca299d95..d4f135205 100644
--- a/opentech/apply/projects/urls.py
+++ b/opentech/apply/projects/urls.py
@@ -6,6 +6,8 @@ from .views import (
     ChangePaymentRequestStatusView,
     DeletePaymentRequestView,
     EditPaymentRequestView,
+    PaymentRequestView,
+    PaymentRequestPrivateMedia,
     ProjectDetailSimplifiedView,
     ProjectDetailView,
     ProjectEditView,
@@ -38,16 +40,13 @@ if settings.PROJECTS_ENABLED:
                 SelectDocumentView.as_view(),
                 name="copy-documents",
             ),
-            path(
-                'delete-payment-request/<int:payment_request_id>/',
-                DeletePaymentRequestView.as_view(),
-                name='delete-payment-request',
-            ),
-            path(
-                'edit-payment-request/<int:payment_request_id>/',
-                EditPaymentRequestView.as_view(),
-                name='edit-payment-request',
-            ),
             path('simplified/', ProjectDetailSimplifiedView.as_view(), name='simplified'),
+            path('payment-requests/<int:pr_pk>/', include(([
+                path('', PaymentRequestView.as_view(), name='detail'),
+                path('edit/', EditPaymentRequestView.as_view(), name='edit'),
+                path('delete/', DeletePaymentRequestView.as_view(), name='delete'),
+                path('documents/invoice/', PaymentRequestPrivateMedia.as_view(), name="invoice"),
+                path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"),
+            ], 'payments'))),
         ])),
     ]
diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py
new file mode 100644
index 000000000..fa45c29c1
--- /dev/null
+++ b/opentech/apply/projects/views/__init__.py
@@ -0,0 +1,2 @@
+from .payment import *  # NOQA
+from .project import *  # NOQA
diff --git a/opentech/apply/projects/views/payment.py b/opentech/apply/projects/views/payment.py
new file mode 100644
index 000000000..952ee30ee
--- /dev/null
+++ b/opentech/apply/projects/views/payment.py
@@ -0,0 +1,202 @@
+from django.contrib.auth.decorators import login_required
+from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import PermissionDenied
+from django.db import transaction
+from django.http import Http404
+from django.shortcuts import get_object_or_404, redirect
+from django.utils.decorators import method_decorator
+from django.views.generic import (
+    CreateView,
+    DeleteView,
+    DetailView,
+    UpdateView
+)
+
+from opentech.apply.activity.messaging import MESSAGES, messenger
+from opentech.apply.users.decorators import staff_required
+from opentech.apply.utils.storage import PrivateMediaView
+from opentech.apply.utils.views import (
+    DelegateableView,
+    DelegatedViewMixin,
+    ViewDispatcher,
+)
+
+from ..forms import (
+    ChangePaymentRequestStatusForm,
+    EditPaymentRequestForm,
+    RequestPaymentForm,
+)
+from ..models import (
+    PaymentRequest,
+    Project
+)
+
+
+@method_decorator(login_required, name='dispatch')
+class PaymentRequestAccessMixin:
+    model = PaymentRequest
+    pk_url_kwarg = 'pr_pk'
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+        if self.get_object().project != self.project:
+            raise Http404
+
+        is_admin = request.user.is_apply_staff
+        is_owner = request.user == self.project.user
+        if not (is_owner or is_admin):
+            raise PermissionDenied
+
+        return super().dispatch(request, *args, **kwargs)
+
+
+@method_decorator(staff_required, name='dispatch')
+class ChangePaymentRequestStatusView(DelegatedViewMixin, PaymentRequestAccessMixin, UpdateView):
+    form_class = ChangePaymentRequestStatusForm
+    context_name = 'change_payment_status'
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('user')
+        return kwargs
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+            related=self.object,
+        )
+
+        return response
+
+
+class DeletePaymentRequestView(DeleteView):
+    model = PaymentRequest
+    pk_url_kwarg = 'payment_request_id'
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+
+        self.object = self.get_object()
+        if not self.object.user_can_delete(request.user):
+            raise PermissionDenied
+
+        return super().dispatch(request, *args, **kwargs)
+
+    @transaction.atomic()
+    def delete(self, request, *args, **kwargs):
+        response = super().delete(request, *args, **kwargs)
+
+        messenger(
+            MESSAGES.DELETE_PAYMENT_REQUEST,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+            related=self.object,
+        )
+
+        return response
+
+    def get_success_url(self):
+        return self.project.get_absolute_url()
+
+
+class PaymentRequestAdminView(PaymentRequestAccessMixin, DelegateableView, DetailView):
+    form_views = [
+        ChangePaymentRequestStatusView
+    ]
+    template_name_suffix = '_admin_detail'
+
+
+class PaymentRequestApplicantView(PaymentRequestAccessMixin, DelegateableView, DetailView):
+    form_views = []
+
+
+class PaymentRequestView(ViewDispatcher):
+    admin_view = PaymentRequestAdminView
+    applicant_view = PaymentRequestApplicantView
+
+
+class EditPaymentRequestView(PaymentRequestAccessMixin, UpdateView):
+    form_class = EditPaymentRequestForm
+
+    def dispatch(self, request, *args, **kwargs):
+        payment_request = self.get_object()
+        if not payment_request.can_user_edit(request.user):
+            return redirect(payment_request)
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_valid(self, form):
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            request=self.request,
+            user=self.request.user,
+            source=self.project,
+            related=self.object,
+        )
+
+        return response
+
+    def get_success_url(self):
+        return self.project.get_absolute_url()
+
+
+@method_decorator(login_required, name='dispatch')
+class PaymentRequestPrivateMedia(UserPassesTestMixin, PrivateMediaView):
+    raise_exception = True
+
+    def dispatch(self, *args, **kwargs):
+        project_pk = self.kwargs['pk']
+        payment_pk = self.kwargs['pr_pk']
+        self.project = get_object_or_404(Project, pk=project_pk)
+        self.payment_request = get_object_or_404(PaymentRequest, pk=payment_pk)
+
+        if self.payment_request.project != self.project:
+            raise Http404
+
+        return super().dispatch(*args, **kwargs)
+
+    def get_media(self, *args, **kwargs):
+        file_pk = kwargs.get('file_pk')
+        if not file_pk:
+            return self.payment_request.invoice
+
+        receipt = get_object_or_404(self.payment_request.receipts, pk=file_pk)
+        return receipt.file
+
+    def test_func(self):
+        if self.request.user.is_apply_staff:
+            return True
+
+        if self.request.user == self.project.user:
+            return True
+
+        return False
+
+
+class RequestPaymentView(DelegatedViewMixin, CreateView):
+    context_name = 'request_payment_form'
+    form_class = RequestPaymentForm
+    model = PaymentRequest
+
+    def form_valid(self, form):
+        project = self.kwargs['object']
+
+        form.instance.by = self.request.user
+        form.instance.project = project
+        response = super().form_valid(form)
+
+        messenger(
+            MESSAGES.REQUEST_PAYMENT,
+            request=self.request,
+            user=self.request.user,
+            source=project,
+        )
+
+        return response
diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views/project.py
similarity index 62%
rename from opentech/apply/projects/views.py
rename to opentech/apply/projects/views/project.py
index 6b62ab416..871629184 100644
--- a/opentech/apply/projects/views.py
+++ b/opentech/apply/projects/views/project.py
@@ -1,4 +1,3 @@
-import decimal
 from copy import copy
 
 from django.contrib import messages
@@ -6,7 +5,6 @@ from django.contrib.auth.decorators import login_required
 from django.contrib.auth.mixins import UserPassesTestMixin
 from django.core.exceptions import PermissionDenied
 from django.db import transaction
-from django.db.models import Q
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect
 from django.utils.decorators import method_decorator
@@ -14,7 +12,6 @@ from django.utils.functional import cached_property
 from django.utils.translation import ugettext_lazy as _
 from django.views.generic import (
     CreateView,
-    DeleteView,
     DetailView,
     FormView,
     UpdateView
@@ -22,225 +19,62 @@ from django.views.generic import (
 
 from opentech.apply.activity.messaging import MESSAGES, messenger
 from opentech.apply.activity.views import ActivityContextMixin, CommentFormView
-from opentech.apply.users.decorators import staff_required
+from opentech.apply.users.decorators import approver_required, staff_required
 from opentech.apply.utils.storage import PrivateMediaView
 from opentech.apply.utils.views import (
     DelegateableView,
     DelegatedViewMixin,
-    ViewDispatcher
+    ViewDispatcher,
 )
 
-from .files import get_files
-from .forms import (
+from ..files import get_files
+from ..forms import (
     ApproveContractForm,
-    ChangePaymentRequestStatusForm,
     CreateApprovalForm,
-    EditPaymentRequestForm,
     ProjectApprovalForm,
     ProjectEditForm,
     RejectionForm,
     RemoveDocumentForm,
-    RequestPaymentForm,
     SelectDocumentForm,
     SetPendingForm,
+    StaffUploadContractForm,
     UpdateProjectLeadForm,
     UploadContractForm,
     UploadDocumentForm
 )
-from .models import (
-    CHANGES_REQUESTED,
+from ..models import (
     CONTRACTING,
-    DECLINED,
     IN_PROGRESS,
-    PAID,
     PROJECT_STATUS_CHOICES,
-    REQUEST_STATUS_CHOICES,
-    SUBMITTED,
-    UNDER_REVIEW,
     Approval,
     Contract,
     PacketFile,
-    PaymentRequest,
     Project
 )
+from .payment import RequestPaymentView
 
 
-class ContractsMixin:
-    def get_context_data(self, **kwargs):
-        project = self.get_object()
-        contracts = (project.contracts.select_related('approver')
-                                      .order_by('-created_at'))
-
-        latest_contract = self.get_contract_to_approve(contracts)
-
-        contracts = contracts.filter(is_signed=True, approver__isnull=False)
-
-        if latest_contract:
-            contracts = [latest_contract, *contracts]
-
-        context = super().get_context_data(**kwargs)
-        context['latest_contract'] = latest_contract
-        context['contracts'] = contracts
-        return context
-
-    def get_contract_to_approve(self, contracts):
-        """If there's a contract to approve, get that"""
-        latest = contracts.first()
-
-        if not latest:
-            return
-
-        if latest.approver:
-            return
-
-        return latest
-
-
-class PaymentsMixin:
-    def get_context_data(self, **kwargs):
-        project = self.get_object()
-
-        payments = {
-            'availabe_statuses': REQUEST_STATUS_CHOICES,
-            'not_rejected': project.payment_requests.exclude(status=DECLINED),
-            'rejected': project.payment_requests.filter(status=DECLINED),
-            'totals': self.get_totals(project),
-        }
-
-        context = super().get_context_data(**kwargs)
-        context['payments'] = payments
-        context['edit_payment_request_forms'] = list(self.get_edit_payment_request_forms())
-        return context
-
-    def get_edit_payment_request_forms(self):
-        """
-        Get an iterable of EditPaymentRequestForms
-
-        We want to instantiate each EditPaymentRequestForm with a given
-        PaymentRequest.  Each subclass of this mixin defines
-        .get_payment_requests_queryset() so we can change the available forms
-        based on the type of user viewing (applicant or staff).
-        """
-        payment_requests = self.get_payment_requests_queryset().select_related('project')
-        for payment_request in payment_requests:
-            yield EditPaymentRequestForm(instance=payment_request)
-
-    def get_totals(self, project):
-        def percentage(total, value):
-            if not total:
-                return decimal.Decimal(0)
-
-            unrounded_total = (value / total) * 100
-
-            # round using Decimal since we're dealing with currency
-            rounded_total = unrounded_total.quantize(
-                decimal.Decimal('0.0'),
-                rounding=decimal.ROUND_DOWN,
-            )
-
-            return rounded_total
-
-        unpaid_requests = project.payment_requests.filter(Q(status=SUBMITTED) | Q(status=UNDER_REVIEW))
-        awaiting_absolute = sum(unpaid_requests.values_list('value', flat=True))
-        awaiting_percentage = percentage(project.value, awaiting_absolute)
-
-        paid_requests = project.payment_requests.filter(status=PAID)
-        paid_absolute = sum(paid_requests.values_list('value', flat=True))
-        paid_percentage = percentage(project.value, paid_absolute)
-
-        return {
-            'awaiting_absolute': awaiting_absolute,
-            'awaiting_percentage': awaiting_percentage,
-            'paid_absolute': paid_absolute,
-            'paid_percentage': paid_percentage,
-        }
-
-
-class SubmissionFilesMixin:
-    """
-    Mixin to provide an instantiated SelectDocumentForm
-    """
-    def get_context_data(self, **kwargs):
-        project = self.get_object()
-
-        files = get_files(project)
-
-        context = super().get_context_data(**kwargs)
-        context['select_document_form'] = SelectDocumentForm(files, project)
-        return context
-
+# APPROVAL VIEWS
 
 @method_decorator(staff_required, name='dispatch')
-class ApproveContractView(UpdateView):
-    form_class = ApproveContractForm
-    model = Contract
-    pk_url_kwarg = 'contract_pk'
-
-    def dispatch(self, request, *args, **kwargs):
-        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
-        return super().dispatch(request, *args, **kwargs)
-
-    def form_invalid(self, form):
-        for error in form.errors:
-            messages.error(self.request, error)
-
-        return redirect(self.project)
-
-    def form_valid(self, form):
-        with transaction.atomic():
-            form.instance.approver = self.request.user
-            form.instance.project = self.project
-            response = super().form_valid(form)
-
-            messenger(
-                MESSAGES.APPROVE_CONTRACT,
-                request=self.request,
-                user=self.request.user,
-                source=self.project,
-                related=self.object,
-            )
-
-            self.project.status = IN_PROGRESS
-            self.project.save(update_fields=['status'])
-
-        return response
-
-    def get_success_url(self):
-        return self.project.get_absolute_url()
-
-
-@method_decorator(staff_required, name='dispatch')
-class ChangePaymentRequestStatusView(UpdateView):
-    form_class = ChangePaymentRequestStatusForm
-    model = PaymentRequest
-    pk_url_kwarg = 'payment_request_id'
-
-    def dispatch(self, request, *args, **kwargs):
-        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
-        return super().dispatch(request, *args, **kwargs)
-
-    def form_invalid(self, form):
-        for error in form.errors:
-            messages.error(self.request, error)
-
-        return redirect(self.project)
+class SendForApprovalView(DelegatedViewMixin, UpdateView):
+    context_name = 'request_approval_form'
+    form_class = SetPendingForm
+    model = Project
 
     def form_valid(self, form):
+        # lock project
         response = super().form_valid(form)
 
         messenger(
-            MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS,
+            MESSAGES.SEND_FOR_APPROVAL,
             request=self.request,
             user=self.request.user,
-            source=self.project,
-            related=self.object,
+            source=self.object,
         )
 
         return response
 
-    def get_success_url(self):
-        return self.project.get_absolute_url()
-
 
 @method_decorator(staff_required, name='dispatch')
 class CreateApprovalView(DelegatedViewMixin, CreateView):
@@ -248,10 +82,17 @@ class CreateApprovalView(DelegatedViewMixin, CreateView):
     form_class = CreateApprovalForm
     model = Approval
 
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        return kwargs
+
     @transaction.atomic()
     def form_valid(self, form):
         project = self.kwargs['object']
+
         form.instance.project = project
+
         response = super().form_valid(form)
 
         messenger(
@@ -270,93 +111,49 @@ class CreateApprovalView(DelegatedViewMixin, CreateView):
         return response
 
 
-class DeletePaymentRequestView(DeleteView):
-    model = PaymentRequest
-    pk_url_kwarg = 'payment_request_id'
-
-    def dispatch(self, request, *args, **kwargs):
-        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
-
-        self.object = self.get_object()
-        if not self.object.user_can_delete(request.user):
-            raise PermissionDenied
-
-        return super().dispatch(request, *args, **kwargs)
-
-    @transaction.atomic()
-    def delete(self, request, *args, **kwargs):
-        response = super().delete(request, *args, **kwargs)
+@method_decorator(approver_required, name='dispatch')
+class RejectionView(DelegatedViewMixin, UpdateView):
+    context_name = 'rejection_form'
+    form_class = RejectionForm
+    model = Project
 
+    def form_valid(self, form):
         messenger(
-            MESSAGES.DELETE_PAYMENT_REQUEST,
+            MESSAGES.REQUEST_PROJECT_CHANGE,
             request=self.request,
             user=self.request.user,
-            source=self.project,
-            related=self.object,
+            source=self.object,
+            comment=form.cleaned_data['comment'],
         )
 
-        return response
-
-    def get_success_url(self):
-        return self.project.get_absolute_url()
-
-
-class EditPaymentRequestView(UpdateView):
-    form_class = EditPaymentRequestForm
-    model = PaymentRequest
-    pk_url_kwarg = 'payment_request_id'
+        self.object.is_locked = False
+        self.object.save(update_fields=['is_locked'])
 
-    def dispatch(self, request, *args, **kwargs):
-        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+        return redirect(self.object)
 
-        is_admin = request.user.is_apply_staff
-        is_owner = request.user == self.project.user
-        if not (is_owner or is_admin):
-            raise PermissionDenied
 
-        return super().dispatch(request, *args, **kwargs)
+# PROJECT DOCUMENTS
 
-    def form_invalid(self, form):
-        messages.error(self.request, form.errors)
-        return redirect(self.project)
+@method_decorator(staff_required, name='dispatch')
+class UploadDocumentView(DelegatedViewMixin, CreateView):
+    context_name = 'document_form'
+    form_class = UploadDocumentForm
+    model = Project
 
     def form_valid(self, form):
+        project = self.kwargs['object']
+        form.instance.project = project
         response = super().form_valid(form)
 
         messenger(
-            MESSAGES.UPDATE_PAYMENT_REQUEST,
+            MESSAGES.UPLOAD_DOCUMENT,
             request=self.request,
             user=self.request.user,
-            source=self.project,
-            related=self.object,
+            source=project,
         )
 
         return response
 
-    def get_success_url(self):
-        return self.project.get_absolute_url()
-
-
-@method_decorator(staff_required, name='dispatch')
-class RejectionView(DelegatedViewMixin, UpdateView):
-    context_name = 'rejection_form'
-    form_class = RejectionForm
-    model = Project
-
-    def form_valid(self, form):
-        messenger(
-            MESSAGES.REQUEST_PROJECT_CHANGE,
-            request=self.request,
-            user=self.request.user,
-            source=self.object,
-            comment=form.cleaned_data['comment'],
-        )
-
-        self.object.is_locked = False
-        self.object.save(update_fields=['is_locked'])
-
-        return redirect(self.object)
-
 
 @method_decorator(staff_required, name='dispatch')
 class RemoveDocumentView(DelegatedViewMixin, FormView):
@@ -376,33 +173,11 @@ class RemoveDocumentView(DelegatedViewMixin, FormView):
         return redirect(project)
 
 
-class RequestPaymentView(DelegatedViewMixin, CreateView):
-    context_name = 'request_payment_form'
-    form_class = RequestPaymentForm
-    model = PaymentRequest
-
-    def form_valid(self, form):
-        project = self.kwargs['object']
-
-        form.instance.by = self.request.user
-        form.instance.project = project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.APPROVE_PROJECT,
-            request=self.request,
-            user=self.request.user,
-            source=project,
-        )
-
-        return response
-
-
 @method_decorator(login_required, name='dispatch')
-class SelectDocumentView(SubmissionFilesMixin, FormView):
-    context_name = 'select_document_form'
+class SelectDocumentView(DelegatedViewMixin, CreateView):
     form_class = SelectDocumentForm
-    model = Project
+    context_name = 'select_document_form'
+    model = PacketFile
 
     def dispatch(self, request, *args, **kwargs):
         self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
@@ -415,6 +190,9 @@ class SelectDocumentView(SubmissionFilesMixin, FormView):
         return redirect(self.project)
 
     def form_valid(self, form):
+        form.instance.project = self.project
+        form.instance.name = form.instance.document.name
+
         response = super().form_valid(form)
 
         messenger(
@@ -428,33 +206,13 @@ class SelectDocumentView(SubmissionFilesMixin, FormView):
 
     def get_form_kwargs(self):
         kwargs = super().get_form_kwargs()
-        kwargs['existing_files'] = get_files(self.project)
-        kwargs['project'] = self.project
+        kwargs.pop('user')
+        kwargs.pop('instance')
+        kwargs['existing_files'] = get_files(self.get_parent_object())
         return kwargs
 
-    def get_success_url(self):
-        return self.project.get_absolute_url()
-
-
-@method_decorator(staff_required, name='dispatch')
-class SendForApprovalView(DelegatedViewMixin, UpdateView):
-    context_name = 'request_approval_form'
-    form_class = SetPendingForm
-    model = Project
-
-    def form_valid(self, form):
-        # lock project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.SEND_FOR_APPROVAL,
-            request=self.request,
-            user=self.request.user,
-            source=self.object,
-        )
-
-        return response
 
+# GENERAL FORM VIEWS
 
 @method_decorator(staff_required, name='dispatch')
 class UpdateLeadView(DelegatedViewMixin, UpdateView):
@@ -479,10 +237,81 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView):
         return response
 
 
+# CONTRACTS
+
+class ContractsMixin:
+    def get_context_data(self, **kwargs):
+        project = self.get_object()
+        contracts = (project.contracts.select_related('approver')
+                                      .order_by('-created_at'))
+
+        latest_contract = self.get_contract_to_approve(contracts)
+
+        contracts = contracts.filter(is_signed=True, approver__isnull=False)
+
+        if latest_contract:
+            contracts = [latest_contract, *contracts]
+
+        context = super().get_context_data(**kwargs)
+        context['latest_contract'] = latest_contract
+        context['contracts'] = contracts
+        return context
+
+    def get_contract_to_approve(self, contracts):
+        """If there's a contract to approve, get that"""
+        latest = contracts.first()
+
+        if not latest:
+            return
+
+        if latest.approver:
+            return
+
+        return latest
+
+
+@method_decorator(staff_required, name='dispatch')
+class ApproveContractView(UpdateView):
+    form_class = ApproveContractForm
+    model = Contract
+    pk_url_kwarg = 'contract_pk'
+
+    def dispatch(self, request, *args, **kwargs):
+        self.project = get_object_or_404(Project, pk=self.kwargs['pk'])
+        return super().dispatch(request, *args, **kwargs)
+
+    def form_invalid(self, form):
+        for error in form.errors:
+            messages.error(self.request, error)
+
+        return redirect(self.project)
+
+    def form_valid(self, form):
+        with transaction.atomic():
+            form.instance.approver = self.request.user
+            form.instance.project = self.project
+            response = super().form_valid(form)
+
+            messenger(
+                MESSAGES.APPROVE_CONTRACT,
+                request=self.request,
+                user=self.request.user,
+                source=self.project,
+                related=self.object,
+            )
+
+            self.project.status = IN_PROGRESS
+            self.project.save(update_fields=['status'])
+
+        return response
+
+    def get_success_url(self):
+        return self.project.get_absolute_url()
+
+
 @method_decorator(login_required, name='dispatch')
 class UploadContractView(DelegatedViewMixin, CreateView):
     context_name = 'contract_form'
-    form_class = UploadContractForm
     model = Project
 
     def dispatch(self, request, *args, **kwargs):
@@ -495,8 +324,20 @@ class UploadContractView(DelegatedViewMixin, CreateView):
 
         return response
 
+    def get_form_class(self):
+        if self.request.user.is_apply_staff:
+            return StaffUploadContractForm
+        return UploadContractForm
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs.pop('instance')
+        kwargs.pop('user')
+        return kwargs
+
     def form_valid(self, form):
         project = self.kwargs['object']
+
         form.instance.project = project
 
         if self.request.user == project.user:
@@ -514,33 +355,12 @@ class UploadContractView(DelegatedViewMixin, CreateView):
         return response
 
 
-@method_decorator(staff_required, name='dispatch')
-class UploadDocumentView(DelegatedViewMixin, CreateView):
-    context_name = 'document_form'
-    form_class = UploadDocumentForm
-    model = Project
-
-    def form_valid(self, form):
-        project = self.kwargs['object']
-        form.instance.project = project
-        response = super().form_valid(form)
-
-        messenger(
-            MESSAGES.UPLOAD_DOCUMENT,
-            request=self.request,
-            user=self.request.user,
-            source=project,
-        )
-
-        return response
-
+# PROJECT VIEW
 
 class AdminProjectDetailView(
     ActivityContextMixin,
     DelegateableView,
     ContractsMixin,
-    PaymentsMixin,
-    SubmissionFilesMixin,
     DetailView,
 ):
     form_views = [
@@ -549,6 +369,7 @@ class AdminProjectDetailView(
         RejectionView,
         RemoveDocumentView,
         RequestPaymentView,
+        SelectDocumentView,
         SendForApprovalView,
         UpdateLeadView,
         UploadContractView,
@@ -563,29 +384,15 @@ class AdminProjectDetailView(
         context['current_status_index'] = [status for status, _ in PROJECT_STATUS_CHOICES].index(self.object.status)
         context['approvals'] = self.object.approvals.distinct('by')
         context['approve_contract_form'] = ApproveContractForm()
-        context['change_payment_request_status_forms'] = list(self.get_change_payment_request_status_forms())
         context['remaining_document_categories'] = list(self.object.get_missing_document_categories())
         return context
 
-    def get_change_payment_request_status_forms(self):
-        """
-        Get an iterable of ChangePaymentRequestStatusForms
-
-        We want to filter the available options based on the current
-        PaymentRequest object so we need to initialise those forms outside of
-        the template.
-        """
-        for payment_request in self.object.payment_requests.exclude(status=DECLINED).select_related('project'):
-            yield ChangePaymentRequestStatusForm(instance=payment_request)
-
-    def get_payment_requests_queryset(self):
-        return self.object.payment_requests.filter(status=SUBMITTED)
 
-
-class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, ContractsMixin, PaymentsMixin, DetailView):
+class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, ContractsMixin, DetailView):
     form_views = [
         CommentFormView,
         RequestPaymentView,
+        SelectDocumentView,
         UploadContractView,
     ]
 
@@ -594,13 +401,14 @@ class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, Contrac
 
     def dispatch(self, request, *args, **kwargs):
         project = self.get_object()
-        # This view is only for applicants.
         if project.user != request.user:
             raise PermissionDenied
         return super().dispatch(request, *args, **kwargs)
 
-    def get_payment_requests_queryset(self):
-        return self.object.payment_requests.filter(status__in=[SUBMITTED, CHANGES_REQUESTED])
+
+class ProjectDetailView(ViewDispatcher):
+    admin_view = AdminProjectDetailView
+    applicant_view = ApplicantProjectDetailView
 
 
 @method_decorator(login_required, name='dispatch')
@@ -628,10 +436,7 @@ class ProjectPrivateMediaView(UserPassesTestMixin, PrivateMediaView):
         return False
 
 
-class ProjectDetailView(ViewDispatcher):
-    admin_view = AdminProjectDetailView
-    applicant_view = ApplicantProjectDetailView
-
+# PROJECT EDIT
 
 @method_decorator(staff_required, name='dispatch')
 class ProjectDetailSimplifiedView(DetailView):
diff --git a/opentech/apply/users/decorators.py b/opentech/apply/users/decorators.py
index 26d4016f5..b41a4aaee 100644
--- a/opentech/apply/users/decorators.py
+++ b/opentech/apply/users/decorators.py
@@ -19,8 +19,16 @@ def is_apply_staff(user):
     return True
 
 
+def is_approver(user):
+    if not user.is_approver:
+        raise PermissionDenied
+    return True
+
+
 staff_required = [login_required, user_passes_test(is_apply_staff)]
 
+approver_required = [login_required, user_passes_test(is_approver)]
+
 
 def superuser_decorator(fn):
     check = user_passes_test(lambda user: user.is_superuser)
diff --git a/opentech/apply/users/models.py b/opentech/apply/users/models.py
index 768b583c9..1c60d84be 100644
--- a/opentech/apply/users/models.py
+++ b/opentech/apply/users/models.py
@@ -2,7 +2,6 @@ from django.contrib.auth.hashers import make_password
 from django.contrib.auth.models import AbstractUser, BaseUserManager, Group
 from django.db import models
 from django.db.models import Q
-from django.urls import reverse
 from django.utils.functional import cached_property
 from django.utils.translation import gettext_lazy as _
 
@@ -102,9 +101,6 @@ class User(AbstractUser):
 
     objects = UserManager()
 
-    def get_absolute_url(self):
-        return reverse('wagtailusers_users:edit', args=(self.id,))
-
     def __str__(self):
         return self.get_full_name() if self.get_full_name() else self.get_short_name()
 
diff --git a/opentech/apply/users/wagtail_hooks.py b/opentech/apply/users/wagtail_hooks.py
index dcd5276f5..5e865f259 100644
--- a/opentech/apply/users/wagtail_hooks.py
+++ b/opentech/apply/users/wagtail_hooks.py
@@ -1,4 +1,5 @@
 from django.conf.urls import url
+from django.urls import reverse
 
 from wagtail.core import hooks
 
@@ -20,6 +21,7 @@ def notify_after_create_user(request, user):
         message=f'{request.user} has crated a new account for {user}.',
         request=request,
         related=user,
+        path=reverse('wagtailusers_users:edit', args=(user.id,))
     )
 
 
@@ -32,4 +34,5 @@ def notify_after_edit_user(request, user):
             message=f'{request.user} has edited the account for {user} that now have these roles: {roles}.',
             request=request,
             related=user,
+            path=reverse('wagtailusers_users:edit', args=(user.id,))
         )
diff --git a/opentech/apply/utils/notifications.py b/opentech/apply/utils/notifications.py
index bed3683da..037b23a32 100644
--- a/opentech/apply/utils/notifications.py
+++ b/opentech/apply/utils/notifications.py
@@ -19,16 +19,15 @@ class SlackNotifications():
                 slack_users.append(f'<{user.slack}>')
         return ' '.join(slack_users)
 
-    def slack_link(self, request, related):
-        slack_link = ''
+    def slack_link(self, request, related, path=None, **kwargs):
         try:
-            link = request.scheme + '://' + request.get_host() + related.get_absolute_url()
+            url = path or related.get_absolute_url()
         except AttributeError:
-            pass
-        else:
-            title = str(related)
-            slack_link = f'<{link}|{title}>'
-        return slack_link
+            return ''
+
+        link = request.scheme + '://' + request.get_host() + url
+        title = str(related)
+        return f'<{link}|{title}>'
 
     def send_message(self, message, request, recipients=None, related=None, **kwargs):
         if not self.destination or not self.target_room:
@@ -41,7 +40,7 @@ class SlackNotifications():
 
         slack_users = self.slack_users(recipients) if recipients else ''
 
-        slack_link = self.slack_link(request, related) if related else ''
+        slack_link = self.slack_link(request, related, **kwargs) if related else ''
 
         message = ' '.join([slack_users, message, slack_link]).strip()
 
diff --git a/opentech/apply/utils/templatetags/__init__.py b/opentech/apply/utils/templatetags/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/opentech/apply/utils/templatetags/apply_tags.py b/opentech/apply/utils/templatetags/apply_tags.py
new file mode 100644
index 000000000..c010e4d6c
--- /dev/null
+++ b/opentech/apply/utils/templatetags/apply_tags.py
@@ -0,0 +1,9 @@
+from django import template
+
+register = template.Library()
+
+
+# Get the verbose name of a model instance
+@register.filter
+def model_verbose_name(instance):
+    return instance._meta.verbose_name.title()
diff --git a/opentech/apply/utils/testing/tests.py b/opentech/apply/utils/testing/tests.py
index 2940aceb2..b52c1d389 100644
--- a/opentech/apply/utils/testing/tests.py
+++ b/opentech/apply/utils/testing/tests.py
@@ -36,7 +36,7 @@ class BaseViewTestCase(TestCase):
     def get_kwargs(self, instance):
         return {}
 
-    def url(self, instance, view_name=None, absolute=True, kwargs=dict()):
+    def url(self, instance, view_name=None, absolute=True, url_kwargs=None):
         view = view_name or self.base_view_name
         full_url_name = self.url_name.format(view)
         kwargs_method = f'get_{view}_kwargs'
@@ -44,6 +44,8 @@ class BaseViewTestCase(TestCase):
             kwargs = getattr(self, kwargs_method)(instance)
         else:
             kwargs = self.get_kwargs(instance)
+        if url_kwargs:
+            kwargs.update(url_kwargs)
         return self.url_from_pattern(full_url_name, kwargs, secure=True, absolute=absolute)
 
     def absolute_url(self, location, secure=True):
@@ -57,11 +59,11 @@ class BaseViewTestCase(TestCase):
         request = self.factory.get(url, secure=secure)
         return request.path
 
-    def get_page(self, instance=None, view_name=None):
-        return self.client.get(self.url(instance, view_name), secure=True, follow=True)
+    def get_page(self, instance=None, view_name=None, url_kwargs=None):
+        return self.client.get(self.url(instance, view_name, url_kwargs=url_kwargs), secure=True, follow=True)
 
-    def post_page(self, instance=None, data=dict(), view_name=None):
-        return self.client.post(self.url(instance, view_name), data, secure=True, follow=True)
+    def post_page(self, instance=None, data=dict(), view_name=None, url_kwargs=None):
+        return self.client.post(self.url(instance, view_name, url_kwargs=url_kwargs), data, secure=True, follow=True)
 
     def refresh(self, instance):
         return instance.__class__.objects.get(id=instance.id)
diff --git a/opentech/apply/utils/views.py b/opentech/apply/utils/views.py
index 6ea0fb167..17f5faa3d 100644
--- a/opentech/apply/utils/views.py
+++ b/opentech/apply/utils/views.py
@@ -7,6 +7,7 @@ from django.views.generic import View
 from django.views.generic.base import ContextMixin
 from django.views.generic.detail import SingleObjectTemplateResponseMixin
 from django.views.generic.edit import ModelFormMixin, ProcessFormView
+from django.shortcuts import redirect
 
 
 def page_not_found(request, exception=None, template_name='apply/404.html'):
@@ -60,12 +61,22 @@ class DelegatableBase(ContextMixin):
     """
     form_prefix = 'form-submitted-'
 
+    def __init__(self, *args, **kwargs):
+        self._form_views = {
+            self.form_prefix + form_view.context_name: form_view
+            for form_view in self.form_views
+        }
+
     def get_form_kwargs(self):
         return {}
 
     def get_context_data(self, **kwargs):
-        form_kwargs = self.get_form_kwargs()
-        forms = dict(form_view.contribute_form(**form_kwargs) for form_view in self.form_views)
+        forms = {}
+        for form_view in self._form_views.values():
+            view = form_view()
+            view.setup(self.request, self.args, self.kwargs)
+            context_key, form = view.contribute_form(self)
+            forms[context_key] = form
 
         return super().get_context_data(
             form_prefix=self.form_prefix,
@@ -78,12 +89,12 @@ class DelegatableBase(ContextMixin):
         kwargs['context'] = self.get_context_data()
         kwargs['template_names'] = self.get_template_names()
 
-        for form_view in self.form_views:
-            if self.form_prefix + form_view.context_name in request.POST:
-                return form_view.as_view()(request, *args, **kwargs)
+        for form_key, form_view in self._form_views.items():
+            if form_key in request.POST:
+                return form_view.as_view()(request, *args, parent=self, **kwargs)
 
         # Fall back to get if not form exists as submitted
-        return self.get(request, *args, **kwargs)
+        return redirect(request.path)
 
 
 class DelegateableView(DelegatableBase):
@@ -115,17 +126,46 @@ class DelegateableListView(DelegatableBase):
 class DelegatedViewMixin(View):
     """For use on create views accepting forms from another view"""
 
+    # TODO: REMOVE IN DJANGO 2.2
+    def setup(self, request, *args, **kwargs):
+        """Initialize attributes shared by all view methods."""
+        self.request = request
+        self.args = args
+        self.kwargs = kwargs
+
+    def get_object(self):
+        # We want to make sure we share the same instance between the form
+        # and the view where appropriate
+        parent_object = self.get_parent_kwargs()['instance']
+        if isinstance(parent_object, self.model):
+            return parent_object
+
+        return super().get_object()
+
     def get_template_names(self):
         return self.kwargs['template_names']
 
+    def get_form_name(self):
+        return self.context_name
+
     def get_form_kwargs(self):
-        kwargs = super().get_form_kwargs()
-        kwargs['user'] = self.request.user
-        return kwargs
+        form_kwargs = super().get_form_kwargs()
+        form_kwargs['user'] = self.request.user
+        form_kwargs.update(**self.get_parent_kwargs())
+        return form_kwargs
+
+    def get_parent_kwargs(self):
+        try:
+            return self.parent.get_form_kwargs()
+        except AttributeError:
+            return self.kwargs['parent'].get_form_kwargs()
+
+    def get_parent_object(self):
+        return self.get_parent_kwargs()['instance']
 
     def get_form(self, *args, **kwargs):
         form = super().get_form(*args, **kwargs)
-        form.name = self.context_name
+        form.name = self.get_form_name()
         return form
 
     def get_context_data(self, **kwargs):
@@ -139,11 +179,10 @@ class DelegatedViewMixin(View):
     def is_model_form(cls):
         return issubclass(cls.form_class, ModelForm)
 
-    @classmethod
-    def contribute_form(cls, **kwargs):
-        form = cls.form_class(**kwargs)
-        form.name = cls.context_name
-        return cls.context_name, form
+    def contribute_form(self, parent):
+        self.parent = parent
+        form = self.get_form()
+        return self.context_name, form
 
     def get_success_url(self):
         query = self.request.GET.urlencode()
diff --git a/opentech/static_src/src/sass/apply/components/_docs-block.scss b/opentech/static_src/src/sass/apply/components/_docs-block.scss
index b48aac80a..eae2ff83a 100644
--- a/opentech/static_src/src/sass/apply/components/_docs-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_docs-block.scss
@@ -95,6 +95,12 @@
         font-weight: $weight--bold;
         margin-right: 1rem;
 
+        &:disabled,
+        &.is-disabled {
+            color: $color--mid-grey;
+            cursor: default;
+        }
+
         &:last-child {
             margin-right: 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
index 32dc22e92..97e4d726c 100644
--- a/opentech/static_src/src/sass/apply/components/_payment-block.scss
+++ b/opentech/static_src/src/sass/apply/components/_payment-block.scss
@@ -139,12 +139,12 @@
 
     &__table-status {
         min-width: 160px;
-        width: 30%;
+        width: 25%;
     }
 
-    &__table-docs {
+    &__table-date{
         min-width: 180px;
-        width: 20%;
+        width: 25%;
     }
 
     &__table-update {
-- 
GitLab