diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index eb739abd536d80619a4115c151e73a6aaed2d3f1..ecc7a64f0df995ae4b34c62f1ded4f6ea4bf9eae 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -742,6 +742,7 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.BATCH_REVIEWERS_UPDATED: 'batch_reviewers_updated', MESSAGES.BATCH_TRANSITION: 'batch_transition', MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations', + MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document "{title}"', } def batch_reviewers_updated(self, added, sources, **kwargs): diff --git a/opentech/apply/activity/migrations/0037_add_upload_document.py b/opentech/apply/activity/migrations/0037_add_upload_document.py new file mode 100644 index 0000000000000000000000000000000000000000..6220a12883f325980e280180b82d442270229fdb --- /dev/null +++ b/opentech/apply/activity/migrations/0037_add_upload_document.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-08 10:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0036_add_reject_project'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REJECT_PROJECT', 'Project was Rejected'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0038_auto_20190808_1142.py b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py new file mode 100644 index 0000000000000000000000000000000000000000..47109db845f978f04c7889ca37c1e290ea0f4c15 --- /dev/null +++ b/opentech/apply/activity/migrations/0038_auto_20190808_1142.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.13 on 2019-08-08 10:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0037_add_upload_document'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 6300607c6dc5d672db4a5be39ba70988028935a1..5780d17cf685852e4f7b5cf22c6fd39a6f641a36 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -32,6 +32,7 @@ class MESSAGES(Enum): SEND_FOR_APPROVAL = 'Send for Approval' APPROVE_PROJECT = 'Project was Approved' REQUEST_PROJECT_CHANGE = 'Project change requested' + UPLOAD_DOCUMENT = 'Document was Uploaded to Project' @classmethod def choices(cls): diff --git a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html index c6f0746dc01080d2b61ae9874c63155cb935f8c0..fddbc6ec6cd742d63332a7842300f21c581eb196 100644 --- a/opentech/apply/funds/templates/funds/includes/delegated_form_base.html +++ b/opentech/apply/funds/templates/funds/includes/delegated_form_base.html @@ -1,5 +1,5 @@ {% 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 %} <div class="form__item"> {{ form }} diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index 0599a29d7b0873f6666f9e0fcaa13472fb93efd6..4fdf55a1935e62832a0de6661572f6d0a47d2506 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -5,7 +5,7 @@ from addressfield.fields import AddressField from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.users.groups import STAFF_GROUP_NAME -from .models import COMMITTED, Approval, Project +from .models import COMMITTED, Approval, PacketFile, Project class CreateProjectForm(forms.Form): @@ -96,6 +96,16 @@ class SetPendingForm(forms.ModelForm): return super().save(*args, **kwargs) +class UploadDocumentForm(forms.ModelForm): + class Meta: + fields = ['title', 'category', 'document'] + model = PacketFile + widgets = {'title': forms.TextInput()} + + def __init__(self, user=None, instance=None, *args, **kwargs): + super().__init__(*args, **kwargs) + + class UpdateProjectLeadForm(forms.ModelForm): class Meta: fields = ['lead'] diff --git a/opentech/apply/projects/migrations/0011_add_packet_file.py b/opentech/apply/projects/migrations/0011_add_packet_file.py new file mode 100644 index 0000000000000000000000000000000000000000..bcf80d0576d0dbf8b2d00d10667c3c03f0e3ba8b --- /dev/null +++ b/opentech/apply/projects/migrations/0011_add_packet_file.py @@ -0,0 +1,25 @@ +# Generated by Django 2.0.13 on 2019-08-07 15:50 + +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0010_add_related_names_to_approval_fks'), + ] + + operations = [ + migrations.CreateModel( + name='PacketFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.TextField()), + ('document', models.FileField(upload_to=opentech.apply.projects.models.document_path)), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.DocumentCategory')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='packet_files', to='application_projects.Project')), + ], + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index e83ca544085f5512aac8e6d77c8c2c42c88c9744..e2055aa7b1e8d946e341a5a7733e4ed9d61c17ac 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -1,3 +1,4 @@ +import collections import decimal from django.conf import settings @@ -22,6 +23,21 @@ class Approval(models.Model): 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' CONTRACTING = 'contracting' PROJECT_STATUS_CHOICES = [ @@ -129,6 +145,24 @@ class Project(models.Model): correct_state = self.status == COMMITTED and not self.is_locked return correct_state and self.user_has_updated_details + def get_missing_document_categories(self): + """ + Get the number of documents required to meet each DocumentCategorys minimum + """ + # Count the number of documents in each category currently + existing_categories = DocumentCategory.objects.filter(packet_files__project=self) + counter = collections.Counter(existing_categories) + + # Find the difference between the current count and recommended count + for category in DocumentCategory.objects.all(): + current_count = counter[category] + difference = category.recommended_minimum - current_count + if difference > 0: + yield { + 'category': category, + 'difference': difference, + } + class DocumentCategory(models.Model): name = models.CharField(max_length=254) 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 80ed895b7f05020eb5c5735af9b70ad8a52bf6c9..bb52d2879a3e2cf1dfbf16fe7de0f50810d15663 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -56,36 +56,30 @@ Every project should include the following documents: </p> <ul> - {% for category in remaining_document_categories %} - <li>{{ category.name }} ({{ category.recommended_minimum }})</li> + {% for missing in remaining_document_categories %} + <li>{{ missing.category.name }} ({{ missing.difference }})</li> {% endfor %} </ul> </div> {% endif %} - <!-- Example document list - <ul class="docs-block__document-list"> - <li class="docs-block__document"> - <div class="docs-block__document-inner"> - <p class="docs-block__document-info"><b>This is a document title</b></p> - <p class="docs-block__document-info">This is a document type</p> - </div> - <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> - <li class="docs-block__document"> - <div class="docs-block__document-inner"> - <p class="docs-block__document-info"><b>This is a document title</b></p> - <p class="docs-block__document-info">This is a document type</p> - </div> - <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> - --> + + {% if object.packet_files.exists %} + <ul class="docs-block__document-list"> + {% for document in object.packet_files.all %} + <li class="docs-block__document"> + <div class="docs-block__document-inner"> + <p class="docs-block__document-info"><b>{{ document.title }}</b></p> + <p class="docs-block__document-info">{{ document.category.name }}</p> + </div> + <div class="docs-block__document-inner"> + <a class="docs-block__document-link" href="{{ document.document.url }}">Download</a> + <a class="docs-block__document-link" href="#">Remove</a> + </div> + </li> + {% endfor %} + </ul> + {% endif %} + </li> </ul> <div class="docs-block__buttons"> @@ -103,12 +97,5 @@ <div class="modal" id="upload-supporting-doc"> <h4 class="modal__header-bar">Upload a new document</h4> - <div class="wrapper--outer-space-medium"> - <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> + {% include 'funds/includes/delegated_form_base.html' with form=document_form value='Upload'%} </div> diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index 67a5a88694dc40b997fb706f6e1a13442d7b13df..efea3ef464b886fac564a3fc26fbb1c1ccc97db6 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -5,7 +5,8 @@ import factory from django.utils import timezone 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 ADDRESS = { @@ -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): submission = factory.SubFactory(ApplicationSubmissionFactory) user = factory.SubFactory(UserFactory) @@ -49,3 +58,14 @@ class ProjectFactory(factory.DjangoModelFactory): class Meta: model = Project + + +class PacketFileFactory(factory.DjangoModelFactory): + category = factory.SubFactory(DocumentCategoryFactory) + project = factory.SubFactory(ProjectFactory) + + title = factory.Sequence('name {}'.format) + document = factory.django.FileField() + + class Meta: + model = PacketFile diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index 09754f23ec9a9330e7fc26e77bf83f65a800365c..ded83fe644999416caf85afe0ef196c180b707fd 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -3,6 +3,8 @@ from django.test import TestCase from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory from ..models import Project +from .factories import (DocumentCategoryFactory, PacketFileFactory, + ProjectFactory) class TestProjectModel(TestCase): @@ -14,3 +16,46 @@ class TestProjectModel(TestCase): self.assertEquals(project.submission, submission) self.assertEquals(project.title, submission.title) self.assertEquals(project.user, submission.user) + + def test_get_missing_document_categories_with_enough_documents(self): + project = ProjectFactory() + category = DocumentCategoryFactory(recommended_minimum=1) + PacketFileFactory(project=project, category=category) + + self.assertEqual(project.packet_files.count(), 1) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 0) + + def test_get_missing_document_categories_with_no_documents(self): + project = ProjectFactory() + category = DocumentCategoryFactory(recommended_minimum=1) + + self.assertEqual(project.packet_files.count(), 0) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 1) + self.assertEqual(missing[0]['category'], category) + self.assertEqual(missing[0]['difference'], 1) + + def test_get_missing_document_categories_with_some_documents(self): + project = ProjectFactory() + + category1 = DocumentCategoryFactory(recommended_minimum=5) + PacketFileFactory(project=project, category=category1) + PacketFileFactory(project=project, category=category1) + + category2 = DocumentCategoryFactory(recommended_minimum=3) + PacketFileFactory(project=project, category=category2) + + self.assertEqual(project.packet_files.count(), 3) + + missing = list(project.get_missing_document_categories()) + + self.assertEqual(len(missing), 2) + self.assertEqual(missing[0]['category'], category1) + self.assertEqual(missing[0]['difference'], 3) + self.assertEqual(missing[1]['category'], category2) + self.assertEqual(missing[1]['difference'], 2) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index c234c9141fb0eb03bd3320a9666d601f07692493..f30bdd547e7111dd26bedfd24615aa6c213313b3 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -1,3 +1,5 @@ +from io import BytesIO + from django.test import RequestFactory, TestCase from opentech.apply.users.tests.factories import (ReviewerFactory, @@ -8,7 +10,7 @@ from opentech.apply.utils.testing.tests import BaseViewTestCase from ..forms import SetPendingForm from ..views import ProjectDetailView -from .factories import ProjectFactory +from .factories import DocumentCategoryFactory, ProjectFactory class TestCreateApprovalView(BaseViewTestCase): @@ -104,3 +106,31 @@ class TestSendForApprovalView(BaseViewTestCase): self.assertTrue(project.is_locked) self.assertEqual(project.status, 'committed') + + +class TestUploadDocumentView(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {'pk': instance.id} + + def test_upload_document(self): + category = DocumentCategoryFactory() + project = ProjectFactory() + + test_doc = BytesIO(b'somebinarydata') + test_doc.name = 'document.pdf' + + response = self.post_page(project, { + 'form-submitted-document_form': '', + 'title': 'test document', + 'category': category.id, + 'document': test_doc, + }) + self.assertEqual(response.status_code, 200) + + project.refresh_from_db() + + self.assertEqual(project.packet_files.count(), 1) diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py index 984764f17e432d0e7b0de5c81720ce82df607c1d..38059fa387ac356a5055b21e4ed6c3e987e694a9 100644 --- a/opentech/apply/projects/views.py +++ b/opentech/apply/projects/views.py @@ -12,8 +12,8 @@ from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin, ViewDispatcher) from .forms import (CreateApprovalForm, ProjectEditForm, RejectionForm, - SetPendingForm, UpdateProjectLeadForm) -from .models import CONTRACTING, Approval, DocumentCategory, Project + SetPendingForm, UpdateProjectLeadForm, UploadDocumentForm) +from .models import CONTRACTING, Approval, Project @method_decorator(staff_required, name='dispatch') @@ -106,6 +106,28 @@ class UpdateLeadView(DelegatedViewMixin, UpdateView): return response +@method_decorator(staff_required, name='dispatch') +class UploadDocumentView(DelegatedViewMixin, CreateView): + context_name = 'document_form' + form_class = UploadDocumentForm + model = Project + + def form_valid(self, form): + project = self.kwargs['object'] + form.instance.project = project + response = super().form_valid(form) + + messenger( + MESSAGES.UPLOAD_DOCUMENT, + request=self.request, + user=self.request.user, + source=project, + title=form.instance.title + ) + + return response + + class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): form_views = [ CommentFormView, @@ -113,6 +135,7 @@ class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView) RejectionView, SendForApprovalView, UpdateLeadView, + UploadDocumentView, ] model = Project template_name_suffix = '_admin_detail' @@ -120,7 +143,7 @@ class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) 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