diff --git a/addressfield/fields.py b/addressfield/fields.py index 7692b9eb466616fc1bf94e1ee1ee077f6f4b8f1e..f039b7181e04d5af874dafcb857074ac020eb492 100644 --- a/addressfield/fields.py +++ b/addressfield/fields.py @@ -14,6 +14,10 @@ with open(filepath, encoding='utf8') as address_data: VALIDATION_DATA = {country['iso']: country for country in countries} +ADDRESS_FIELDS_ORDER = [ + 'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country' +] + def flatten_data(data): flattened = dict() diff --git a/opentech/apply/activity/templates/messages/email/applicant_base.html b/opentech/apply/activity/templates/messages/email/applicant_base.html index d88936a30de58750551dbeeb82e37a434286fb99..842c1d00557f70110efd1af47c8fe72d4a93a733 100644 --- a/opentech/apply/activity/templates/messages/email/applicant_base.html +++ b/opentech/apply/activity/templates/messages/email/applicant_base.html @@ -1,7 +1,7 @@ {% extends "messages/email/base.html" %} {% block salutation %}Dear {{ source.user.get_full_name|default:"applicant" }},{% endblock %} -{% block more_info %}You can access your application here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% block more_info %}You can find more information here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} If you have any questions, please submit them here: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}#communications If you have any issues accessing the submission system or other general inquiries, please email us at hello@opentech.fund diff --git a/opentech/apply/activity/templates/messages/email/comment.html b/opentech/apply/activity/templates/messages/email/comment.html index 79c0cf52595125cfc7def594c40777542f81633e..e89ca785f5cd7252b0251d08d72a4bb4c493aedd 100644 --- a/opentech/apply/activity/templates/messages/email/comment.html +++ b/opentech/apply/activity/templates/messages/email/comment.html @@ -1,5 +1,5 @@ {% extends "messages/email/applicant_base.html" %} -{% block content %}There has been a new comment on your application: {{ source.title }} +{% block content %}There has been a new comment on "{{ source.title }}" {{ comment.user }}: {{ comment.message }}{% endblock %} diff --git a/opentech/apply/funds/blocks.py b/opentech/apply/funds/blocks.py index bec9b723dbd973e0a47b979aa320d28387084fe9..c0cbb6e2d4a8da9fb74ad1df3df461d53343f3e1 100644 --- a/opentech/apply/funds/blocks.py +++ b/opentech/apply/funds/blocks.py @@ -5,7 +5,7 @@ from django.utils.translation import ugettext_lazy as _ from wagtail.core import blocks -from addressfield.fields import AddressField +from addressfield.fields import AddressField, ADDRESS_FIELDS_ORDER from opentech.apply.categories.blocks import CategoryQuestionBlock from opentech.apply.stream_forms.blocks import FormFieldsBlock from opentech.apply.utils.blocks import ( @@ -69,18 +69,15 @@ class AddressFieldBlock(ApplicationSingleIncludeFieldBlock): # Based on the fields listed in addressfields/widgets.py return ', '.join( data[field] - for field in order_fields + for field in ADDRESS_FIELDS_ORDER if data[field] ) def prepare_data(self, value, data, serialize): - order_fields = [ - 'thoroughfare', 'premise', 'localityname', 'administrativearea', 'postalcode', 'country' - ] data = json.loads(data) data = { field: data[field] - for field in order_fields + for field in ADDRESS_FIELDS_ORDER } if serialize: diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html index 1f4d62f15e7ba45024aa8dea06eb9f3e8c991b07..49940120e0f2499ac37544762e5c4d3b5e3c70d9 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_admin_detail.html @@ -36,10 +36,6 @@ </div> {% endblock %} -{% block project %} - {% include 'funds/includes/project_block.html' %} -{% endblock %} - {% block screening_status %} {% include 'funds/includes/screening_status_block.html' %} {% endblock %} diff --git a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html index cbd7575fe0bd9c25e5f737e986e5cc9d41d73fa3..0583233d383fb2aaea67e4de8e36096900fa6d70 100644 --- a/opentech/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/opentech/apply/funds/templates/funds/applicationsubmission_detail.html @@ -99,9 +99,8 @@ {% endblock %} {% endif %} - {% if request.user.is_apply_staff and object.project %} - {% block project %} - {% endblock %} + {% if object.project %} + {% include 'funds/includes/project_block.html' %} {% endif %} {% block determination %} diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index c7e51daf69864d1faeb6635fab8afc7c0693a89c..13d19826fdf490fba6a6f3b2ca7b60e92158837f 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -61,6 +61,8 @@ class ProjectEditForm(forms.ModelForm): 'proposed_start': forms.DateInput, } + +class ProjectApprovalForm(ProjectEditForm): def save(self, *args, **kwargs): self.instance.user_has_updated_details = True return super().save(*args, **kwargs) diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 441070ab4fac5e9be8ae8dbec6d657d47de73468..c3fb3e4a6482f831cf2feb250a413251e1af5894 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -1,6 +1,8 @@ import collections import decimal +import json +from addressfield.fields import ADDRESS_FIELDS_ORDER from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError @@ -100,6 +102,14 @@ class Project(models.Model): def __str__(self): return self.title + def get_address_display(self): + address = json.loads(self.contact_address) + return ', '.join( + address.get(field) + for field in ADDRESS_FIELDS_ORDER + if address.get(field) + ) + @classmethod def create_from_submission(cls, submission): """ @@ -134,6 +144,14 @@ class Project(models.Model): if self.proposed_start > self.proposed_end: raise ValidationError(_('Proposed End Date must be after Proposed Start Date')) + def editable_by(self, user): + if self.editable: + return True + + # Approver can edit it when they are approving + return user.is_approver and self.can_make_approval + + @property def editable(self): # Someone must lead the project to make changes return self.lead and not self.is_locked 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 e308cdb7ed390018a0d95c3bffbaf67aaca3bed9..f0da56dcfbf0ada7d37b5813c628903080fdb6f4 100644 --- a/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html +++ b/opentech/apply/projects/templates/application_projects/includes/supporting_documents.html @@ -1,3 +1,6 @@ +{% load approval_tools %} +{% user_can_edit_project object request.user as editable %} + <div class="docs-block wrapper--outer-space-large"> <div class="docs-block__header"> <h4 class="docs-block__heading">Supporting documents</h4> @@ -23,7 +26,7 @@ <p class="docs-block__title">Approval Form</p> </div> <div class="docs-block__row-inner"> - {% if object.editable %} + {% if editable %} <a class="docs-block__link" href="{% url 'apply:projects:edit' pk=object.pk %}"> {% if object.user_has_updated_details %} Edit @@ -45,7 +48,7 @@ <svg class="icon docs-block__icon"><use xlink:href="#tick"></use></svg> <p class="docs-block__title">Supporting documents</p> </div> - {% if object.editable %} + {% if editable %} <div class="docs-block__row-inner"> <a data-fancybox data-src="#upload-supporting-doc" class="docs-block__link" href="#">Upload new</a> </div> diff --git a/opentech/apply/projects/templates/application_projects/project_applicant_detail.html b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..2ca4b45978990b5f19dc4d1b5537fd11141a2aa4 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/project_applicant_detail.html @@ -0,0 +1,18 @@ +{% extends "application_projects/project_detail.html" %} + +{% block notifications %} +{% if not object.editable %} +<div class="wrapper wrapper--sidebar"> + <div class="wrapper--sidebar--inner wrapper--error"> + <div> + <p>Your project is not editable at this point.</p> + {% if not object.lead %} + <p>We are awaiting a lead to be assigned.</p> + {% else %} + <p>It is currently under review by a staff member.</p> + {% endif %} + </div> + </div> +</div> +{% endif %} +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index 01d4870ba4fa3450b860e19bf245e46d88c9c8a8..59f074e20376e6faed4b8b1396ad6bba10d31e53 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -56,26 +56,10 @@ <div class="wrapper wrapper--large wrapper--tabs js-tabs-content"> <div class="tabs__content" id="tab-1"> - + {% block notifications %} + {% endblock %} <div class="wrapper wrapper--sidebar"> <article class="wrapper--sidebar--inner"> - <header class="heading heading--submission-meta heading-text zeta"> - <span>Last edit: <strong>{{ object.updated_at.timestamp.date }} by {{ object.updated_by }}</strong></span> - - {# {% if request.user|has_edit_perm:object %} #} - {% if request.user %} - <a class="link link--edit-submission is-active" href="#"> - Edit - <svg class="icon icon--pen"><use xlink:href="#pen"></use></svg> - </a> - {% else %} - <span class="link link--edit-submission"> - Edit - <svg class="icon icon--padlock"><use xlink:href="#padlock"></use></svg> - </span> - {% endif %} - </header> - <h3>Project Information</h3> <div class="grid grid--proposal-info"> <div> @@ -108,7 +92,7 @@ <div class="rich-text--hidden js-rich-text-hidden"> <div> <h5>Address</h5> - <p>{{ object.contact_address|default:"-" }}</p> + <p>{{ object.get_address_display|default:"-"}}</p> </div> <div> @@ -118,7 +102,7 @@ <div> <h5>Value</h5> - <p>{{ object.value|default:"-" }}</p> + <p>${{ object.value|default:"-" }}</p> </div> </div> @@ -128,9 +112,7 @@ {# {% include "funds/includes/invoice_block.html" %} #} {# </div> #} - {% if request.user.is_apply_staff %} - {% include "application_projects/includes/supporting_documents.html" %} - {% endif %} + {% include "application_projects/includes/supporting_documents.html" %} </article> <aside class="sidebar"> diff --git a/opentech/apply/projects/templates/application_projects/project_form.html b/opentech/apply/projects/templates/application_projects/project_form.html index 4db3df2c7b67ed17a5c9541ab47b39e50ce98443..6834a47d5e56a0cc822ea79be4d4ea0eea28ee70 100644 --- a/opentech/apply/projects/templates/application_projects/project_form.html +++ b/opentech/apply/projects/templates/application_projects/project_form.html @@ -2,12 +2,12 @@ {% load static %} -{% block title %}Editing: {{ object.name }}{% endblock %} +{% block title %}Editing: {{ object.title }}{% endblock %} {% block content %} <div class="admin-bar"> <div class="admin-bar__inner"> - <h2 class="heading heading--no-margin">Editing: {{ object.name }}</h2> + <h2 class="heading heading--no-margin">Editing: {{ object.title}}</h2> </div> </div> diff --git a/opentech/apply/projects/templatetags/approval_tools.py b/opentech/apply/projects/templatetags/approval_tools.py index 1af889f3ef9dd0fca8d85717ca38942b5c784c9f..2c51d1a59462bedbe371bdc18f82c75a6598196a 100644 --- a/opentech/apply/projects/templatetags/approval_tools.py +++ b/opentech/apply/projects/templatetags/approval_tools.py @@ -11,3 +11,8 @@ def user_has_approved(project, user): @register.simple_tag def user_can_approve_project(project, user): return user.is_approver and not user_has_approved(project, user) + + +@register.simple_tag +def user_can_edit_project(project, user): + return project.editable_by(user) diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index efea3ef464b886fac564a3fc26fbb1c1ccc97db6..b91c0aadf1e1c82f41884838451e7852c18018be 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -5,9 +5,13 @@ import factory from django.utils import timezone from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory -from opentech.apply.projects.models import (DocumentCategory, PacketFile, - Project) -from opentech.apply.users.tests.factories import UserFactory +from opentech.apply.projects.models import ( + DocumentCategory, + PacketFile, + Project, +) +from opentech.apply.users.tests.factories import StaffFactory, UserFactory + ADDRESS = { 'country': 'GB', @@ -48,6 +52,7 @@ class ProjectFactory(factory.DjangoModelFactory): user = factory.SubFactory(UserFactory) title = factory.Sequence('name {}'.format) + lead = factory.SubFactory(StaffFactory) contact_legal_name = 'test' contact_email = 'test@example.com' contact_address = json.dumps(ADDRESS) @@ -56,6 +61,8 @@ class ProjectFactory(factory.DjangoModelFactory): proposed_start = factory.LazyFunction(timezone.now) proposed_end = factory.LazyFunction(timezone.now) + is_locked = False + class Meta: model = Project diff --git a/opentech/apply/projects/tests/test_forms.py b/opentech/apply/projects/tests/test_forms.py index ae05b044aa9033cf8e9864d70b27fcf53d93b671..5c0d157d5b81315841a9134cb98551d30d50d1b7 100644 --- a/opentech/apply/projects/tests/test_forms.py +++ b/opentech/apply/projects/tests/test_forms.py @@ -1,10 +1,10 @@ from django.test import TestCase -from ..forms import ProjectEditForm +from ..forms import ProjectApprovalForm from .factories import ProjectFactory, address_to_form_data -class TestProjectEditForm(TestCase): +class TestProjectApprovalForm(TestCase): def test_updating_fields_sets_changed_flag(self): project = ProjectFactory() @@ -20,7 +20,7 @@ class TestProjectEditForm(TestCase): 'proposed_end': project.proposed_end, } data.update(address_to_form_data()) - form = ProjectEditForm(instance=project, data=data) + form = ProjectApprovalForm(instance=project, data=data) form.save() self.assertTrue(project.user_has_updated_details) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 70fc10611cecc90b92bb88e614742506c1f4ecf1..9c5f69a0730f14352beae0ea30176171a936efc8 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -1,17 +1,24 @@ from io import BytesIO +from django.core.exceptions import PermissionDenied from django.test import RequestFactory, TestCase -from opentech.apply.users.tests.factories import (ReviewerFactory, - StaffFactory, - SuperUserFactory, - UserFactory) +from opentech.apply.users.tests.factories import ( + ApproverFactory, + ReviewerFactory, + StaffFactory, + SuperUserFactory, + UserFactory, +) from opentech.apply.utils.testing.tests import BaseViewTestCase from ..forms import SetPendingForm from ..views import ProjectDetailView -from .factories import (DocumentCategoryFactory, PacketFileFactory, - ProjectFactory) +from .factories import ( + DocumentCategoryFactory, + PacketFileFactory, + ProjectFactory, +) class TestCreateApprovalView(BaseViewTestCase): @@ -69,7 +76,16 @@ class TestProjectDetailView(TestCase): request = self.factory.get('/projects/1/') request.user = UserFactory() - response = ProjectDetailView.as_view()(request, pk=self.project.pk) + with self.assertRaises(PermissionDenied): + ProjectDetailView.as_view()(request, pk=self.project.pk) + + def test_owner_has_access(self): + owner = UserFactory() + request = self.factory.get('/projects/1/') + request.user = owner + project = ProjectFactory(user=owner) + + response = ProjectDetailView.as_view()(request, pk=project.pk) self.assertEqual(response.status_code, 200) @@ -164,3 +180,100 @@ class TestUploadDocumentView(BaseViewTestCase): project.refresh_from_db() self.assertEqual(project.packet_files.count(), 1) + + +class BaseProjectEditTestCase(BaseViewTestCase): + url_name = 'funds:projects:{}' + base_view_name = 'edit' + + def get_kwargs(self, instance): + return {'pk': instance.id} + + +class TestUserProjectEditView(BaseProjectEditTestCase): + user_factory = UserFactory + + def test_does_not_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 403) + + def test_owner_has_access(self): + project = ProjectFactory(user=self.user) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_no_lead_redirects(self): + project = ProjectFactory(user=self.user, lead=None) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + def test_locked_redirects(self): + project = ProjectFactory(user=self.user, is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + +class TestStaffProjectEditView(BaseProjectEditTestCase): + user_factory = StaffFactory + + def test_staff_user_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + def test_no_lead_redirects(self): + project = ProjectFactory(user=self.user, lead=None) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + def test_locked_redirects(self): + project = ProjectFactory(user=self.user, is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertRedirects(response, self.url(project, 'detail')) + + +class TestApproverProjectEditView(BaseProjectEditTestCase): + user_factory = ApproverFactory + + def test_approver_has_access_locked(self): + project = ProjectFactory(is_locked=True) + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestSuperProjectEditView(BaseProjectEditTestCase): + user_factory = StaffFactory + + def test_has_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.redirect_chain, []) + + +class TestReviewerProjectEditView(BaseProjectEditTestCase): + user_factory = ReviewerFactory + + def test_does_not_have_access(self): + project = ProjectFactory() + response = self.get_page(project) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.redirect_chain, []) diff --git a/opentech/apply/projects/views.py b/opentech/apply/projects/views.py index a97746f82a3d944eecadc3162c1cc0069f031943..150d6ec9e11db9af7650b1d15c18279db9a6ca7c 100644 --- a/opentech/apply/projects/views.py +++ b/opentech/apply/projects/views.py @@ -1,8 +1,11 @@ from copy import copy +from django.contrib import messages +from django.core.exceptions import PermissionDenied from django.db import transaction from django.shortcuts import redirect from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ from django.views.generic import CreateView, DetailView, FormView, UpdateView from opentech.apply.activity.messaging import MESSAGES, messenger @@ -11,10 +14,17 @@ from opentech.apply.users.decorators import staff_required from opentech.apply.utils.views import (DelegateableView, DelegatedViewMixin, ViewDispatcher) -from .forms import (CreateApprovalForm, ProjectEditForm, RejectionForm, - RemoveDocumentForm, SetPendingForm, UpdateProjectLeadForm, - UploadDocumentForm) -from .models import CONTRACTING, Approval, PacketFile, Project +from .forms import ( + CreateApprovalForm, + ProjectApprovalForm, + ProjectEditForm, + RejectionForm, + RemoveDocumentForm, + SetPendingForm, + UpdateProjectLeadForm, + UploadDocumentForm, +) +from .models import CONTRACTING, Approval, Project, PacketFile @method_decorator(staff_required, name='dispatch') @@ -167,8 +177,20 @@ class AdminProjectDetailView(ActivityContextMixin, DelegateableView, DetailView) return context -class ApplicantProjectDetailView(DetailView): +class ApplicantProjectDetailView(ActivityContextMixin, DelegateableView, DetailView): + form_views = [ + CommentFormView, + ] + model = Project + template_name_suffix = '_applicant_detail' + + 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) class ProjectDetailView(ViewDispatcher): @@ -176,7 +198,35 @@ class ProjectDetailView(ViewDispatcher): applicant_view = ApplicantProjectDetailView -@method_decorator(staff_required, name='dispatch') -class ProjectEditView(UpdateView): +class ProjectApprovalEditView(UpdateView): + form_class = ProjectApprovalForm + model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + return super().dispatch(request, *args, **kwargs) + + +class ApplicantProjectEditView(UpdateView): form_class = ProjectEditForm model = Project + + def dispatch(self, request, *args, **kwargs): + project = self.get_object() + # This view is only for applicants. + if project.user != request.user: + raise PermissionDenied + + if not project.editable_by(request.user): + messages.info(self.request, _('You are not allowed to edit the project at this time')) + return redirect(project) + + return super().dispatch(request, *args, **kwargs) + + +class ProjectEditView(ViewDispatcher): + admin_view = ProjectApprovalEditView + applicant_view = ApplicantProjectEditView diff --git a/opentech/apply/users/tests/factories.py b/opentech/apply/users/tests/factories.py index 8db74b40f779c61361dc8f2b05e6805015b70cb5..5d008ce436562c5c5ecc6a492393b7b340480351 100644 --- a/opentech/apply/users/tests/factories.py +++ b/opentech/apply/users/tests/factories.py @@ -3,7 +3,12 @@ from django.contrib.auth.models import Group import factory -from ..groups import APPLICANT_GROUP_NAME, REVIEWER_GROUP_NAME, STAFF_GROUP_NAME +from ..groups import ( + APPLICANT_GROUP_NAME, + APPROVER_GROUP_NAME, + REVIEWER_GROUP_NAME, + STAFF_GROUP_NAME, +) class GroupFactory(factory.DjangoModelFactory): @@ -57,6 +62,16 @@ class StaffFactory(OAuthUserFactory): self.groups.add(GroupFactory(name=STAFF_GROUP_NAME)) +class ApproverFactory(StaffFactory): + @factory.post_generation + def groups(self, create, extracted, **kwargs): + if create: + self.groups.add( + GroupFactory(name=STAFF_GROUP_NAME), + GroupFactory(name=APPROVER_GROUP_NAME), + ) + + class SuperUserFactory(StaffFactory): is_superuser = True