From 1f52931f8bdb2b4d609fed8cfb821c88c1d9750f Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Fri, 1 Nov 2019 11:01:06 +0000 Subject: [PATCH] Feature/gh 1617 submit a report (#1629) * Add basic reporting to the project * Add file uploading to the reporting * Add activity for submit report --- opentech/apply/activity/messaging.py | 26 ++--- .../migrations/0049_add_submit_report.py | 18 ++++ opentech/apply/activity/options.py | 1 + .../email/payment_request_status_updated.html | 2 +- .../email/payment_request_updated.html | 2 +- .../messages/email/report_submitted.html | 8 ++ opentech/apply/determinations/forms.py | 14 +-- opentech/apply/projects/forms.py | 34 ++++++- .../migrations/0025_add_report_models.py | 64 ++++++++++++ .../0026_data_contract_approved_date.py | 20 ++++ opentech/apply/projects/models.py | 98 ++++++++++++++++++- .../application_projects/project_detail.html | 13 +++ .../application_projects/report_form.html | 39 ++++++++ opentech/apply/projects/tests/factories.py | 30 ++++++ opentech/apply/projects/tests/test_models.py | 77 ++++++++++++++- opentech/apply/projects/tests/test_views.py | 71 +++++++++++++- opentech/apply/projects/urls.py | 8 ++ opentech/apply/projects/views/__init__.py | 1 + opentech/apply/projects/views/project.py | 6 +- opentech/apply/projects/views/report.py | 81 +++++++++++++++ opentech/apply/utils/fields.py | 11 +++ .../src/sass/apply/components/_admin-bar.scss | 14 +++ 22 files changed, 605 insertions(+), 33 deletions(-) create mode 100644 opentech/apply/activity/migrations/0049_add_submit_report.py create mode 100644 opentech/apply/activity/templates/messages/email/report_submitted.html create mode 100644 opentech/apply/projects/migrations/0025_add_report_models.py create mode 100644 opentech/apply/projects/migrations/0026_data_contract_approved_date.py create mode 100644 opentech/apply/projects/templates/application_projects/report_form.html create mode 100644 opentech/apply/projects/views/report.py create mode 100644 opentech/apply/utils/fields.py diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 57a31e798..c9eabcb59 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -64,6 +64,7 @@ neat_related = { MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'payment_request', MESSAGES.DELETE_PAYMENT_REQUEST: 'payment_request', MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request', + MESSAGES.SUBMIT_REPORT: 'report', } @@ -134,14 +135,14 @@ class AdapterBase: event.source.id: event for event in events } - for recipient in self.batch_recipients(message_type, sources, **kwargs): + for recipient in self.batch_recipients(message_type, sources, user=user, **kwargs): recipients = recipient['recipients'] sources = recipient['sources'] events = [events_by_source[source.id] for source in sources] self.process_send(message_type, recipients, events, request, user, sources=sources, source=None, related=related, **kwargs) def process(self, message_type, event, request, user, source, related=None, **kwargs): - recipients = self.recipients(message_type, source=source, related=related, **kwargs) + recipients = self.recipients(message_type, source=source, related=related, user=user, **kwargs) self.process_send(message_type, recipients, [event], request, user, source, related=related, **kwargs) def process_send(self, message_type, recipients, events, request, user, source, sources=list(), related=None, **kwargs): @@ -238,6 +239,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.APPROVE_CONTRACT: 'Approved contract', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'Updated Payment Request status to: {payment_request.status_display}', MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted', + MESSAGES.SUBMIT_REPORT: 'Submitted a report', } def recipients(self, message_type, **kwargs): @@ -405,6 +407,7 @@ class SlackAdapter(AdapterBase): MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: '{user} has changed the status of <{link_related}|payment request> on <{link}|{source.title}> to {payment_request.status_display}.', MESSAGES.DELETE_PAYMENT_REQUEST: '{user} has deleted payment request from <{link}|{source.title}>.', MESSAGES.UPDATE_PAYMENT_REQUEST: '{user} has updated payment request for <{link}|{source.title}>.', + MESSAGES.SUBMIT_REPORT: '{user} has submitted a report for <{link}|{source.title}>.' } def __init__(self): @@ -631,8 +634,9 @@ class EmailAdapter(AdapterBase): MESSAGES.PARTNERS_UPDATED_PARTNER: 'partners_updated_partner', MESSAGES.UPLOAD_CONTRACT: 'messages/email/contract_uploaded.html', MESSAGES.SENT_TO_COMPLIANCE: 'messages/email/sent_to_compliance.html', - MESSAGES.UPDATE_PAYMENT_REQUEST: 'handle_update_payment_request', + MESSAGES.UPDATE_PAYMENT_REQUEST: 'messages/email/payment_request_updated.html', MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated', + MESSAGES.SUBMIT_REPORT: 'messages/email/report_submitted.html', } def get_subject(self, message_type, source): @@ -674,15 +678,6 @@ class EmailAdapter(AdapterBase): old_phase = transitions[submission.id] return self.handle_transition(old_phase=old_phase, source=submission, **kwargs) - def handle_update_payment_request(self, user, **kwargs): - if user.is_applicant: - return - - return self.render_message( - 'messages/email/payment_request_updated.html', - **kwargs, - ) - def handle_payment_status_updated(self, related, **kwargs): return self.render_message( 'messages/email/payment_request_status_updated.html', @@ -708,7 +703,7 @@ class EmailAdapter(AdapterBase): if not comment.priviledged and not comment.user == source.user: return self.render_message('messages/email/comment.html', **kwargs) - def recipients(self, message_type, source, **kwargs): + def recipients(self, message_type, source, user, **kwargs): if is_ready_for_review(message_type): return self.reviewers(source) @@ -731,6 +726,11 @@ class EmailAdapter(AdapterBase): return [project_settings.compliance_email] + if message_type in {MESSAGES.SUBMIT_REPORT, MESSAGES.UPDATE_PAYMENT_REQUEST}: + # Don't tell the user if they did these activities + if user.is_applicant: + return [] + return [source.user.email] def batch_recipients(self, message_type, sources, **kwargs): diff --git a/opentech/apply/activity/migrations/0049_add_submit_report.py b/opentech/apply/activity/migrations/0049_add_submit_report.py new file mode 100644 index 000000000..61ec2c4f3 --- /dev/null +++ b/opentech/apply/activity/migrations/0049_add_submit_report.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-10-30 14:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0048_add_project_transition'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='type', + field=models.CharField(choices=[('UPDATE_LEAD', 'Update Lead'), ('BATCH_UPDATE_LEAD', 'Batch Update Lead'), ('EDIT', 'Edit'), ('APPLICANT_EDIT', 'Applicant Edit'), ('NEW_SUBMISSION', 'New Submission'), ('SCREENING', 'Screening'), ('TRANSITION', 'Transition'), ('BATCH_TRANSITION', 'Batch Transition'), ('DETERMINATION_OUTCOME', 'Determination Outcome'), ('BATCH_DETERMINATION_OUTCOME', 'Batch Determination Outcome'), ('INVITED_TO_PROPOSAL', 'Invited To Proposal'), ('REVIEWERS_UPDATED', 'Reviewers Updated'), ('BATCH_REVIEWERS_UPDATED', 'Batch Reviewers Updated'), ('PARTNERS_UPDATED', 'Partners Updated'), ('PARTNERS_UPDATED_PARTNER', 'Partners Updated Partner'), ('READY_FOR_REVIEW', 'Ready For Review'), ('BATCH_READY_FOR_REVIEW', 'Batch Ready For Review'), ('NEW_REVIEW', 'New Review'), ('COMMENT', 'Comment'), ('PROPOSAL_SUBMITTED', 'Proposal Submitted'), ('OPENED_SEALED', 'Opened Sealed Submission'), ('REVIEW_OPINION', 'Review Opinion'), ('DELETE_SUBMISSION', 'Delete Submission'), ('DELETE_REVIEW', 'Delete Review'), ('CREATED_PROJECT', 'Created Project'), ('UPDATE_PROJECT_LEAD', 'Update Project Lead'), ('EDIT_REVIEW', 'Edit Review'), ('SEND_FOR_APPROVAL', 'Send for Approval'), ('APPROVE_PROJECT', 'Project was Approved'), ('PROJECT_TRANSITION', 'Project was Transitioned'), ('REQUEST_PROJECT_CHANGE', 'Project change requested'), ('UPLOAD_DOCUMENT', 'Document was Uploaded to Project'), ('REMOVE_DOCUMENT', 'Document was Removed from Project'), ('UPLOAD_CONTRACT', 'Contract was Uploaded to Project'), ('APPROVE_CONTRACT', 'Contract was Approved'), ('REQUEST_PAYMENT', 'Payment was requested for Project'), ('UPDATE_PAYMENT_REQUEST_STATUS', 'Updated Payment Request Status'), ('DELETE_PAYMENT_REQUEST', 'Delete Payment Request'), ('SENT_TO_COMPLIANCE', 'Project was sent to Compliance'), ('UPDATE_PAYMENT_REQUEST', 'Updated Payment Request'), ('SUBMIT_REPORT', 'Submit Report')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 7d2f9bb58..e65b5f484 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -42,6 +42,7 @@ class MESSAGES(Enum): DELETE_PAYMENT_REQUEST = 'Delete Payment Request' SENT_TO_COMPLIANCE = 'Project was sent to Compliance' UPDATE_PAYMENT_REQUEST = 'Updated Payment Request' + SUBMIT_REPORT = 'Submit Report' @classmethod def choices(cls): diff --git a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html index c00b187bf..e35b4d1f7 100644 --- a/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html +++ b/opentech/apply/activity/templates/messages/email/payment_request_status_updated.html @@ -1,7 +1,7 @@ {% extends "messages/email/applicant_base.html" %} {% block content %} -An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. +An {{ ORG_SHORT_NAME }} staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. It is now {{ payment_request.get_status_display }}. {% if has_changes_requested %} diff --git a/opentech/apply/activity/templates/messages/email/payment_request_updated.html b/opentech/apply/activity/templates/messages/email/payment_request_updated.html index 368b14344..a1af1fa8e 100644 --- a/opentech/apply/activity/templates/messages/email/payment_request_updated.html +++ b/opentech/apply/activity/templates/messages/email/payment_request_updated.html @@ -1,7 +1,7 @@ {% extends "messages/email/applicant_base.html" %} {% block content %} -An OTF staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. +An {{ ORG_SHORT_NAME }} staff member has updated your payment request for {{ source.title }} for period {{ payment_request.date_from }} to {{ payment_request.date_to }}. It is now {{ payment_request.get_status_display }}. Title: {{ source.title }} diff --git a/opentech/apply/activity/templates/messages/email/report_submitted.html b/opentech/apply/activity/templates/messages/email/report_submitted.html new file mode 100644 index 000000000..eb5296424 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/report_submitted.html @@ -0,0 +1,8 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} +An {{ ORG_SHORT_NAME }} staff member has submitted a report for {{ source.title }} for period ending {{ report.end_date }}. + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/opentech/apply/determinations/forms.py b/opentech/apply/determinations/forms.py index 12a3417a0..4a94b7b24 100644 --- a/opentech/apply/determinations/forms.py +++ b/opentech/apply/determinations/forms.py @@ -2,7 +2,7 @@ from django import forms from django.contrib.auth import get_user_model from django.core.exceptions import NON_FIELD_ERRORS -from opentech.apply.utils.options import RICH_TEXT_WIDGET +from opentech.apply.utils.fields import RichTextField from opentech.apply.funds.models import ApplicationSubmission from .models import ( @@ -16,18 +16,6 @@ from .utils import determination_actions User = get_user_model() -class RichTextField(forms.CharField): - widget = RICH_TEXT_WIDGET - - def __init__(self, *args, required=False, **kwargs): - kwargs.update(required=required) - super().__init__(*args, **kwargs) - - -class RequiredRichTextField(forms.CharField): - widget = RICH_TEXT_WIDGET - - class BaseDeterminationForm: def __init__(self, *args, user, initial, action, **kwargs): if 'site' in kwargs: diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index 5dc566f88..e4ae81e4f 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -5,11 +5,13 @@ from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.db import transaction from django.db.models import Q +from django.utils import timezone from addressfield.fields import AddressField from opentech.apply.funds.models import ApplicationSubmission from opentech.apply.stream_forms.fields import MultiFileField from opentech.apply.users.groups import STAFF_GROUP_NAME +from opentech.apply.utils.fields import RichTextField from .models import ( CHANGES_REQUESTED, @@ -24,7 +26,10 @@ from .models import ( PacketFile, PaymentReceipt, PaymentRequest, - Project + Project, + Report, + ReportVersion, + ReportPrivateFiles, ) @@ -369,3 +374,30 @@ class UpdateProjectLeadForm(forms.ModelForm): lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id) .filter(qwargs) .distinct()) + + +class ReportEditForm(forms.ModelForm): + content = RichTextField(required=True) + files = MultiFileField(required=False) + + class Meta: + model = Report + fields = ['public'] + + def save(self, commit=True): + instance = super().save(commit) + + version = ReportVersion.objects.create( + report=instance, + content=self.cleaned_data['content'], + submitted=timezone.now(), + ) + + files = self.cleaned_data['files'] + if files: + ReportPrivateFiles.objects.bulk_create( + ReportPrivateFiles(report=version, file=file) + for file in files + ) + + return instance diff --git a/opentech/apply/projects/migrations/0025_add_report_models.py b/opentech/apply/projects/migrations/0025_add_report_models.py new file mode 100644 index 000000000..8a351cc56 --- /dev/null +++ b/opentech/apply/projects/migrations/0025_add_report_models.py @@ -0,0 +1,64 @@ +# Generated by Django 2.1.11 on 2019-10-28 14:15 + +import django.core.files.storage +from django.db import migrations, models +import django.db.models.deletion +import opentech.apply.projects.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0024_allow_no_comments_on_pr'), + ] + + operations = [ + migrations.CreateModel( + name='Report', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public', models.BooleanField(default=True)), + ('end_date', models.DateField()), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='application_projects.Project')), + ], + options={ + 'ordering': ('-end_date',), + }, + ), + migrations.CreateModel( + name='ReportConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('schedule_start', models.DateField(null=True)), + ('occurrence', models.PositiveSmallIntegerField(default=1)), + ('frequency', models.CharField(choices=[('week', 'Weeks'), ('month', 'Months')], default='month', max_length=5)), + ('project', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='report_config', to='application_projects.Project')), + ], + ), + migrations.CreateModel( + name='ReportPrivateFiles', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('document', models.FileField(storage=django.core.files.storage.FileSystemStorage(), upload_to=opentech.apply.projects.models.document_path)), + ], + ), + migrations.CreateModel( + name='ReportVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submitted', models.DateTimeField()), + ('content', models.TextField()), + ('report', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='versions', to='application_projects.Report')), + ], + ), + migrations.AddField( + model_name='contract', + name='approved_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='reportprivatefiles', + name='report', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='application_projects.ReportVersion'), + ), + ] diff --git a/opentech/apply/projects/migrations/0026_data_contract_approved_date.py b/opentech/apply/projects/migrations/0026_data_contract_approved_date.py new file mode 100644 index 000000000..92ba2db6c --- /dev/null +++ b/opentech/apply/projects/migrations/0026_data_contract_approved_date.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.11 on 2019-10-29 03:54 + +from django.db import migrations +from django.db.models import F + + +def copy_submitted_date(apps, schema_editor): + Contract = apps.get_model('application_projects', 'Contract') + Contract.objects.all().update(approved_at=F('created_at')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0025_add_report_models'), + ] + + operations = [ + migrations.RunPython(copy_submitted_date), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 7e2739bd1..eb812096d 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -4,7 +4,7 @@ import json import logging import os - +from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import JSONField @@ -79,6 +79,7 @@ class Contract(models.Model): is_signed = models.BooleanField("Signed?", default=False) created_at = models.DateTimeField(auto_now_add=True) + approved_at = models.DateTimeField(null=True) objects = ContractQuerySet.as_manager() @@ -384,6 +385,15 @@ class Project(BaseStreamForm, AccessFormData, models.Model): value=submission.form_data.get('value', 0), ) + @property + def start_date(self): + # Assume project starts when OTF are happy with the first signed contract + first_approved_contract = self.contracts.approved().order_by('approved_at').first() + if not first_approved_contract: + return None + + return first_approved_contract.approved_at.date() + def paid_value(self): return self.payment_requests.paid_value() @@ -527,3 +537,89 @@ class ProjectApprovalForm(BaseStreamForm, models.Model): def __str__(self): return self.name + + +class ReportConfig(models.Model): + """Persists configuration about the reporting schedule etc""" + + WEEK = "week" + MONTH = "month" + FREQUENCY_CHOICES = [ + (WEEK, "Weeks"), + (MONTH, "Months"), + ] + + project = models.OneToOneField("Project", on_delete=models.CASCADE, related_name="report_config") + schedule_start = models.DateField(null=True) + occurrence = models.PositiveSmallIntegerField(default=1) + frequency = models.CharField(choices=FREQUENCY_CHOICES, default=MONTH, max_length=5) + + def current_due_report(self): + # Project not started - no reporting required + if not self.project.start_date: + return None + + today = timezone.now().date() + + last_report = self.project.reports.filter( + end_date__lt=today, + ).first() + + schedule_date = self.schedule_start or self.project.start_date + + if last_report: + if last_report.end_date < schedule_date: + # reporting schedule changed schedule_start is now the next report date + next_due_date = schedule_date + else: + # we've had a report since the schedule date so base next deadline from the report + next_due_date = self.next_date(last_report.end_date) + else: + # first report required + if schedule_date > today: + # Schedule changed since project inception + next_due_date = schedule_date + else: + # schedule_start is the first day the project so the "last" period + # ended one day before that. If date is in past we required report now + next_due_date = max( + self.next_date(schedule_date - relativedelta(days=1)), + today, + ) + + report, _ = self.project.reports.update_or_create( + project=self.project, + end_date__gte=today, + defaults={'end_date': next_due_date} + ) + return report + + def next_date(self, last_date): + delta_frequency = self.frequency + 's' + delta = relativedelta(**{delta_frequency: self.occurrence}) + next_date = last_date + delta + return next_date + + +class Report(models.Model): + public = models.BooleanField(default=True) + end_date = models.DateField() + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports") + + class Meta: + ordering = ('-end_date',) + + @property + def past_due(self): + return timezone.now().date() > self.end_date + + +class ReportVersion(models.Model): + report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions") + submitted = models.DateTimeField() + content = models.TextField() + + +class ReportPrivateFiles(models.Model): + report = models.ForeignKey("ReportVersion", on_delete=models.CASCADE, related_name="files") + document = models.FileField(upload_to=document_path, storage=PrivateStorage()) diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index de8cabd35..62f722cd4 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -131,6 +131,19 @@ </div> {% endif %} + {% if object.is_in_progress %} + {% with next_report=object.report_config.current_due_report %} + {% if next_report %} + <a + class="button button--primary" + href="{% url "apply:projects:reports:edit" pk=next_report.pk %}" + > + Add Report + </a> + {% endif %} + {% endwith %} + {% endif %} + {% if not object.is_in_progress %} {% include "application_projects/includes/supporting_documents.html" %} {% endif %} diff --git a/opentech/apply/projects/templates/application_projects/report_form.html b/opentech/apply/projects/templates/application_projects/report_form.html new file mode 100644 index 000000000..4095ba8db --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/report_form.html @@ -0,0 +1,39 @@ +{% extends "base-apply.html" %} +{% load static %} + +{% block title %}Report | {{ object.project.title }}{% endblock %} +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner"> + <a class="admin-bar__back-link" href="{{ object.project.get_absolute_url }}"> + Project + </a> + <h2 class="heading heading--no-margin">Report for the period ending {{ report.end_date }}</h2> + <h5 class="heading heading--no-margin">{{ object.project.title }}</h5> + </div> +</div> + +{% include "forms/includes/form_errors.html" with form=form %} + +<div class="wrapper wrapper--light-grey-bg wrapper--form wrapper--sidebar"> + <div class="wrapper--sidebar--inner"> + <form class="form" action="" method="post" enctype="multipart/form-data"> + {% csrf_token %} + {{ form.media }} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + <input class="button button--submit button--top-space button--primary" type="submit" name="save" value="Submit" /> + </form> + </div> +</div> +{% endblock %} + +{% block extra_js %} +<script src="{% static 'js/apply/list-input-files.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index 23a535ed8..f2c96b46a 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -3,6 +3,7 @@ import json import factory import pytz +from dateutil.relativedelta import relativedelta from django.utils import timezone from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory @@ -14,6 +15,8 @@ from opentech.apply.projects.models import ( PaymentRequest, Project, ProjectApprovalForm, + Report, + ReportConfig, ) from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory from opentech.apply.users.tests.factories import StaffFactory, UserFactory @@ -98,6 +101,8 @@ class ProjectFactory(factory.DjangoModelFactory): class ContractFactory(factory.DjangoModelFactory): approver = factory.SubFactory(StaffFactory) project = factory.SubFactory(ProjectFactory) + approved_at = factory.LazyFunction(timezone.now) + is_signed = True file = factory.django.FileField() @@ -137,3 +142,28 @@ class PaymentReceiptFactory(factory.DjangoModelFactory): class Meta: model = PaymentReceipt + + +class ReportConfigFactory(factory.DjangoModelFactory): + project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory") + + class Meta: + model = ReportConfig + django_get_or_create = ('project',) + + +class ReportFactory(factory.DjangoModelFactory): + project = factory.SubFactory("opentech.apply.projects.tests.factories.ApprovedProjectFactory") + end_date = factory.LazyFunction(timezone.now) + + class Meta: + model = Report + + class Params: + past_due = factory.Trait( + end_date=factory.LazyFunction(lambda: timezone.now() - relativedelta(days=1)) + ) + + +class ApprovedProjectFactory(ProjectFactory): + contract = factory.RelatedFactory(ContractFactory, 'project') diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index a467f9f1d..4de065a1f 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -1,6 +1,8 @@ from decimal import Decimal +from dateutil.relativedelta import relativedelta from django.test import TestCase +from django.utils import timezone from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory from opentech.apply.users.tests.factories import ApplicantFactory, StaffFactory @@ -13,12 +15,16 @@ from ..models import ( UNDER_REVIEW, Project, PaymentRequest, + Report, + ReportConfig, ) from .factories import ( DocumentCategoryFactory, PacketFileFactory, PaymentRequestFactory, - ProjectFactory + ProjectFactory, + ReportFactory, + ReportConfigFactory, ) @@ -168,3 +174,72 @@ class TestPaymentRequestsQueryset(TestCase): def test_get_totals_no_value(self): self.assertEqual(PaymentRequest.objects.paid_value(), 0) self.assertEqual(PaymentRequest.objects.unpaid_value(), 0) + + +class TestReportConfigCalculations(TestCase): + + @property + def today(self): + return timezone.now().date() + + def test_next_date_month_from_now(self): + config = ReportConfigFactory() + delta = relativedelta(months=1) + + next_date = config.next_date(self.today) + + self.assertEqual(next_date, self.today + delta) + + def test_next_date_week_from_now(self): + config = ReportConfigFactory(frequency=ReportConfig.WEEK) + delta = relativedelta(weeks=1) + + next_date = config.next_date(self.today) + + self.assertEqual(next_date, self.today + delta) + + def test_months_always_relative(self): + config = ReportConfigFactory(occurrence=2) + last_report = self.today - relativedelta(day=25, months=1) + next_date = config.next_date(last_report) + + self.assertEqual(next_date, last_report + relativedelta(months=2)) + + def test_current_due_report_gets_active_report(self): + config = ReportConfigFactory() + report = ReportFactory(project=config.project) + self.assertEqual(config.current_due_report(), report) + + def test_no_report_creates_report(self): + config = ReportConfigFactory() + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 1) + self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1)) + + def test_no_report_creates_report_not_in_past(self): + config = ReportConfigFactory(schedule_start=self.today - relativedelta(months=3)) + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 1) + self.assertEqual(report.end_date, self.today) + + def test_no_report_schedule_in_future_creates_report(self): + config = ReportConfigFactory(schedule_start=self.today + relativedelta(days=2)) + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 1) + self.assertEqual(report.end_date, self.today + relativedelta(days=2)) + + def test_past_due_report_creates_report(self): + config = ReportConfigFactory(schedule_start=self.today - relativedelta(days=2)) + ReportFactory(project=config.project, end_date=self.today - relativedelta(days=1)) + + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 2) + self.assertEqual(report.end_date, self.today + relativedelta(months=1, days=-1)) + + def test_past_due_report_future_schedule_creates_report(self): + config = ReportConfigFactory(schedule_start=self.today + relativedelta(days=3)) + ReportFactory(project=config.project, end_date=self.today - relativedelta(days=1)) + + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 2) + self.assertEqual(report.end_date, self.today + relativedelta(days=3)) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 8d84549be..a572392f3 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -33,7 +33,8 @@ from .factories import ( PacketFileFactory, PaymentReceiptFactory, PaymentRequestFactory, - ProjectFactory + ProjectFactory, + ReportFactory, ) @@ -1234,3 +1235,71 @@ class TestProjectOverviewView(TestCase): response = self.client.get(url, follow=True) self.assertEqual(response.status_code, 403) + + +class TestStaffSubmitReport(BaseViewTestCase): + base_view_name = 'edit' + url_name = 'funds:projects:reports:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_get_page(self): + report = ReportFactory() + response = self.get_page(report) + self.assertContains(response, report.project.title) + + def test_submit_report(self): + report = ReportFactory() + response = self.post_page(report, {'content': 'Some text', 'public': True}) + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().content, 'Some text') + + def test_change_privacy(self): + report = ReportFactory() + response = self.post_page(report, {'content': 'Some text', 'public': False}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertFalse(report.public) + + +class TestApplicantSubmitReport(BaseViewTestCase): + base_view_name = 'edit' + url_name = 'funds:projects:reports:{}' + user_factory = ApplicantFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_get_own_report(self): + report = ReportFactory(project__user=self.user) + response = self.get_page(report) + self.assertContains(response, report.project.title) + + def test_cant_get_other_report(self): + report = ReportFactory() + response = self.get_page(report) + self.assertEqual(response.status_code, 403) + + def test_submit_own_report(self): + report = ReportFactory(project__user=self.user) + response = self.post_page(report, {'content': 'Some text', 'public': True}) + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().content, 'Some text') + + def test_change_privacy_own(self): + report = ReportFactory(project__user=self.user) + response = self.post_page(report, {'content': 'Some text', 'public': False}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertFalse(report.public) + + def test_cant_submit_other_report(self): + report = ReportFactory() + response = self.post_page(report, {'content': 'Some text', 'public': True}) + self.assertEqual(response.status_code, 403) diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index 3a0e838a8..eb512ba0e 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -14,6 +14,8 @@ from .views import ( ProjectListView, ProjectOverviewView, ProjectPrivateMediaView, + ReportPrivateMedia, + ReportUpdateView, ) app_name = 'projects' @@ -39,4 +41,10 @@ urlpatterns = [ path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"), ])), ], 'payments'))), + path('reports/', include(([ + path('<int:pk>/', include([ + path('edit/', ReportUpdateView.as_view(), name='edit'), + path('documents/<int:file_pk>/', ReportPrivateMedia.as_view(), name="document"), + ])), + ], 'reports'))), ] diff --git a/opentech/apply/projects/views/__init__.py b/opentech/apply/projects/views/__init__.py index fa45c29c1..940f89395 100644 --- a/opentech/apply/projects/views/__init__.py +++ b/opentech/apply/projects/views/__init__.py @@ -1,2 +1,3 @@ from .payment import * # NOQA from .project import * # NOQA +from .report import * # NOQA diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py index 88cf33d6b..ad6481056 100644 --- a/opentech/apply/projects/views/project.py +++ b/opentech/apply/projects/views/project.py @@ -9,6 +9,7 @@ from django.db.models import Count from django.http import Http404 from django.shortcuts import get_object_or_404, redirect from django.urls import reverse, reverse_lazy +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.text import mark_safe @@ -67,6 +68,8 @@ from ..tables import ( ProjectsListTable ) +from .report import ReportingMixin + # APPROVAL VIEWS @@ -319,6 +322,7 @@ class ApproveContractView(DelegatedViewMixin, UpdateView): def form_valid(self, form): with transaction.atomic(): form.instance.approver = self.request.user + form.instance.approved_at = timezone.now() form.instance.project = self.project response = super().form_valid(form) @@ -397,7 +401,7 @@ class UploadContractView(DelegatedViewMixin, CreateView): # PROJECT VIEW -class BaseProjectDetailView(DetailView): +class BaseProjectDetailView(ReportingMixin, DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['statuses'] = PROJECT_STATUS_CHOICES diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py new file mode 100644 index 000000000..28115a366 --- /dev/null +++ b/opentech/apply/projects/views/report.py @@ -0,0 +1,81 @@ +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin +from django.utils.decorators import method_decorator +from django.core.exceptions import PermissionDenied +from django.views.generic import ( + UpdateView +) + +from opentech.apply.activity.messaging import MESSAGES, messenger +from opentech.apply.utils.storage import PrivateMediaView + +from ..models import Report, ReportConfig +from ..forms import ReportEditForm + + +class ReportingMixin: + def dispatch(self, *args, **kwargs): + project = self.get_object() + if project.is_in_progress: + if not hasattr(project, 'report_config'): + ReportConfig.objects.create(project=project) + + return super().dispatch(*args, **kwargs) + + +class ReportAccessMixin: + model = Report + + def dispatch(self, request, *args, **kwargs): + is_admin = request.user.is_apply_staff + is_owner = request.user == self.get_object().project.user + if not (is_owner or is_admin): + raise PermissionDenied + + return super().dispatch(request, *args, **kwargs) + + +@method_decorator(login_required, name='dispatch') +class ReportUpdateView(ReportAccessMixin, UpdateView): + form_class = ReportEditForm + + def get_success_url(self): + return self.object.project.get_absolute_url() + + def form_valid(self, form): + response = super().form_valid(form) + + messenger( + MESSAGES.SUBMIT_REPORT, + request=self.request, + user=self.request.user, + source=self.object.project, + related=self.object, + ) + + return response + + +@method_decorator(login_required, name='dispatch') +class ReportPrivateMedia(UserPassesTestMixin, PrivateMediaView): + raise_exception = True + + def dispatch(self, *args, **kwargs): + report_pk = self.kwargs['pk'] + self.report = get_object_or_404(Report, pk=report_pk) + + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + file_pk = kwargs.get('file_pk') + document = get_object_or_404(self.report.files, pk=file_pk) + return document.document + + def test_func(self): + if self.request.user.is_apply_staff: + return True + + if self.request.user == self.report.project.user: + return True + + return False diff --git a/opentech/apply/utils/fields.py b/opentech/apply/utils/fields.py new file mode 100644 index 000000000..952f37088 --- /dev/null +++ b/opentech/apply/utils/fields.py @@ -0,0 +1,11 @@ +from django import forms + +from .options import RICH_TEXT_WIDGET + + +class RichTextField(forms.CharField): + widget = RICH_TEXT_WIDGET + + def __init__(self, *args, required=False, **kwargs): + kwargs.update(required=required) + super().__init__(*args, **kwargs) diff --git a/opentech/static_src/src/sass/apply/components/_admin-bar.scss b/opentech/static_src/src/sass/apply/components/_admin-bar.scss index ef371d1a7..4004a9d68 100644 --- a/opentech/static_src/src/sass/apply/components/_admin-bar.scss +++ b/opentech/static_src/src/sass/apply/components/_admin-bar.scss @@ -36,4 +36,18 @@ margin: 0 5px; } } + + &__back-link { + display: inline-flex; + align-items: center; + color: $color--lightest-blue; + font-weight: $weight--bold; + + &::before { + @include triangle(top, currentColor, 5px); + margin-right: .5rem; + transform: rotate(-90deg); + } + } + } -- GitLab