Skip to content
Snippets Groups Projects
Commit 972b5c72 authored by George Hickman's avatar George Hickman Committed by Todd Dembrey
Browse files

Upload New Supporting Document (#1391)

* Add PacketFile
* Add upload document form and handler
* List uploaded documents in the supporting docs block
* Add diff count to missing Document Categories

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