From cfc4da4470cb144306c4308d9457f07690404a34 Mon Sep 17 00:00:00 2001 From: Todd Dembrey <todd.dembrey@torchbox.com> Date: Tue, 5 Nov 2019 09:43:15 +0000 Subject: [PATCH] Feature/gh 1621 adjust frequency (#1654) * Allow configuration of the reporting frequency * Add frequency messaging and tweak the next report builder --- opentech/apply/activity/messaging.py | 12 +++++ .../0051_report_frequency_change.py | 18 +++++++ opentech/apply/activity/options.py | 1 + .../messages/email/report_frequency.html | 13 +++++ opentech/apply/projects/forms.py | 53 +++++++++++++++++++ opentech/apply/projects/models.py | 48 +++++++++++++++-- .../includes/reports.html | 21 ++++++++ opentech/apply/projects/tests/test_models.py | 4 ++ opentech/apply/projects/views/project.py | 3 +- opentech/apply/projects/views/report.py | 36 ++++++++++++- 10 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 opentech/apply/activity/migrations/0051_report_frequency_change.py create mode 100644 opentech/apply/activity/templates/messages/email/report_frequency.html diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index b1dd270ed..bfa925a2e 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -66,6 +66,7 @@ neat_related = { MESSAGES.UPDATE_PAYMENT_REQUEST: 'payment_request', MESSAGES.SUBMIT_REPORT: 'report', MESSAGES.SKIPPED_REPORT: 'report', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'config', } @@ -242,6 +243,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.REQUEST_PAYMENT: 'Payment Request submitted', MESSAGES.SUBMIT_REPORT: 'Submitted a report', MESSAGES.SKIPPED_REPORT: 'handle_skipped_report', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency', } def recipients(self, message_type, **kwargs): @@ -342,6 +344,10 @@ class ActivityAdapter(AdapterBase): return ' '.join(message) + def handle_report_frequency(self, config, **kwargs): + new_schedule = config.get_frequency_display() + return f"Updated reporting frequency. New schedule is: {new_schedule} starting on {config.schedule_start}" + def handle_skipped_report(self, report, **kwargs): if report.skipped: return "Skipped a Report" @@ -646,6 +652,7 @@ class EmailAdapter(AdapterBase): MESSAGES.UPDATE_PAYMENT_REQUEST_STATUS: 'handle_payment_status_updated', MESSAGES.SUBMIT_REPORT: 'messages/email/report_submitted.html', MESSAGES.SKIPPED_REPORT: 'messages/email/report_skipped.html', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'messages/email/report_frequency.html', } def get_subject(self, message_type, source): @@ -813,6 +820,7 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.UPLOAD_DOCUMENT: 'Successfully uploaded document', MESSAGES.REMOVE_DOCUMENT: 'Successfully removed document', MESSAGES.SKIPPED_REPORT: 'handle_skipped_report', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency', } def batch_reviewers_updated(self, added, sources, **kwargs): @@ -829,6 +837,10 @@ class DjangoMessagesAdapter(AdapterBase): ', '.join(['"{}"'.format(source.title) for source in sources]) ) + def handle_report_frequency(self, config, **kwargs): + new_schedule = config.get_frequency_display() + return f"Successfully updated reporting frequency. They will now report {new_schedule} starting on {config.schedule_start}" + def handle_skipped_report(self, report, **kwargs): if report.skipped: return f"Successfully skipped a Report for {report.start_date} to {report.end_date}" diff --git a/opentech/apply/activity/migrations/0051_report_frequency_change.py b/opentech/apply/activity/migrations/0051_report_frequency_change.py new file mode 100644 index 000000000..9b64b394e --- /dev/null +++ b/opentech/apply/activity/migrations/0051_report_frequency_change.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-10-31 23:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0050_add_report_skipping_activity'), + ] + + 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'), ('SKIPPED_REPORT', 'Skipped Report'), ('REPORT_FREQUENCY_CHANGED', 'Report Frequency Changed')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 34bd2a901..4e6aea6eb 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -44,6 +44,7 @@ class MESSAGES(Enum): UPDATE_PAYMENT_REQUEST = 'Updated Payment Request' SUBMIT_REPORT = 'Submit Report' SKIPPED_REPORT = 'Skipped Report' + REPORT_FREQUENCY_CHANGED = 'Report Frequency Changed' @classmethod def choices(cls): diff --git a/opentech/apply/activity/templates/messages/email/report_frequency.html b/opentech/apply/activity/templates/messages/email/report_frequency.html new file mode 100644 index 000000000..093142d32 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/report_frequency.html @@ -0,0 +1,13 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} + +An {{ ORG_SHORT_NAME }} staff member has changed the reporting frequency of {{ source.title }}. + +The new schedule is: {{ config.get_frequency_display }} + +The next report is due: {{ config.current_due_report.end_date }} + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index dbbf3125d..44d1ae4fa 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -2,10 +2,12 @@ import functools from django import forms from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.db import transaction from django.db.models import Q from django.utils import timezone +from django.utils.translation import gettext as _ from addressfield.fields import AddressField from opentech.apply.funds.models import ApplicationSubmission @@ -28,6 +30,7 @@ from .models import ( PaymentRequest, Project, Report, + ReportConfig, ReportVersion, ReportPrivateFiles, ) @@ -442,3 +445,53 @@ class ReportEditForm(forms.ModelForm): ) return instance + + +class ReportFrequencyForm(forms.ModelForm): + start = forms.DateField(label='Next report date') + + class Meta: + model = ReportConfig + fields = ('occurrence', 'frequency', 'start') + labels = { + 'occurrence': 'No.', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + today = timezone.now().date() + last_report = self.instance.last_report() + + self.fields['start'].widget.attrs.update({ + 'min': max( + last_report.end_date if last_report else today, + today, + ), + 'max': self.instance.project.end_date, + }) + + def clean_start(self): + start_date = self.cleaned_data['start'] + last_report = self.instance.last_report() + if last_report and start_date <= last_report.end_date: + raise ValidationError( + _("Cannot start a schedule before the current reporting period"), + code="bad_start" + ) + + if start_date < timezone.now().date(): + raise ValidationError( + _("Cannot start a schedule in the past"), + code="bad_start" + ) + + if start_date > self.instance.project.end_date: + raise ValidationError( + _("Cannot start a schedule beyond the end date"), + code="bad_start" + ) + return start_date + + def save(self, *args, **kwargs): + self.instance.schedule_start = self.cleaned_data['start'] + return super().save(*args, **kwargs) diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 5dfd57455..b9a56ec3d 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -7,6 +7,7 @@ import os from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.humanize.templatetags.humanize import ordinal from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -399,6 +400,15 @@ class Project(BaseStreamForm, AccessFormData, models.Model): return first_approved_contract.approved_at.date() + @property + def end_date(self): + # Aiming for the proposed end date as the last day of the project + # If still ongoing assume today is the end + return max( + self.proposed_end.date(), + timezone.now().date(), + ) + def paid_value(self): return self.payment_requests.paid_value() @@ -559,12 +569,36 @@ class ReportConfig(models.Model): occurrence = models.PositiveSmallIntegerField(default=1) frequency = models.CharField(choices=FREQUENCY_CHOICES, default=MONTH, max_length=5) + def get_frequency_display(self): + next_report = self.current_due_report() + + if self.frequency == self.MONTH: + if self.schedule_start and self.schedule_start.day == 31: + day_of_month = 'last day' + else: + day_of_month = ordinal(next_report.end_date.day) + if self.occurrence == 1: + return f"Monthly on the { day_of_month } of the month" + return f"Every { self.occurrence } months on the { day_of_month } of the month" + + weekday = next_report.end_date.strftime('%A') + + if self.occurrence == 1: + return f"Every week on { weekday }" + return f"Every {self.occurrence} weeks on { weekday }" + def past_due_reports(self): return self.project.reports.filter( Q(current__isnull=True) & Q(skipped=False), end_date__lt=timezone.now().date(), ).order_by('end_date') + def last_report(self): + today = timezone.now().date() + return self.project.reports.filter( + Q(end_date__lt=today) | Q(current__isnull=False) + ).first() + def current_due_report(self): # Project not started - no reporting required if not self.project.start_date: @@ -572,9 +606,7 @@ class ReportConfig(models.Model): today = timezone.now().date() - last_report = self.project.reports.filter( - Q(end_date__lt=today) | Q(current__isnull=False) - ).first() + last_report = self.last_report() schedule_date = self.schedule_start or self.project.start_date @@ -587,9 +619,9 @@ class ReportConfig(models.Model): next_due_date = self.next_date(last_report.end_date) else: # first report required - if schedule_date > today: + if self.schedule_start and self.schedule_start >= today: # Schedule changed since project inception - next_due_date = schedule_date + next_due_date = self.schedule_start 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 @@ -676,6 +708,12 @@ class Report(models.Model): return self.project.start_date + def serialize(self): + return { + 'endDate': self.end_date, + 'projectEndDate': self.project.end_date, + } + class ReportVersion(models.Model): report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions") diff --git a/opentech/apply/projects/templates/application_projects/includes/reports.html b/opentech/apply/projects/templates/application_projects/includes/reports.html index bc7844b97..0fd52c8dc 100644 --- a/opentech/apply/projects/templates/application_projects/includes/reports.html +++ b/opentech/apply/projects/templates/application_projects/includes/reports.html @@ -2,6 +2,27 @@ <div class="payment-block__header"> <p class="payment-block__title">Reporting</p> </div> + <div> + <h6>Report frequency</h6> + <p> + {{ object.report_config.get_frequency_display }} + </p> + {% if request.user.is_apply_staff %} + <p> + <div class="modal" id="change-frequency"> + {{ object.report_config.last_report.serialize|json_script:"lastReportData" }} + <h4 class="modal__header-bar">Change reporting frequency</h4> + <p>Schedule reports every:</p> + {% include 'funds/includes/delegated_form_base.html' with form=update_frequency_form value='Continue'%} + </div> + <a data-fancybox + data-src="#change-frequency" + href="#"> + Change + </a> + </p> + {% endif %} + </div> <div> {% for report in object.report_config.past_due_reports %} {% include "application_projects/includes/report_line.html" with report=report %} diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index 52e656587..a214c1e34 100644 --- a/opentech/apply/projects/tests/test_models.py +++ b/opentech/apply/projects/tests/test_models.py @@ -246,6 +246,10 @@ class TestReportConfig(TestCase): self.assertEqual(Report.objects.count(), 2) self.assertEqual(report.end_date, next_due) + def test_today_schedule_gets_report_today(self): + config = ReportConfigFactory(schedule_start=self.today) + self.assertEqual(config.current_due_report().end_date, self.today) + 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)) diff --git a/opentech/apply/projects/views/project.py b/opentech/apply/projects/views/project.py index ad6481056..0f823130b 100644 --- a/opentech/apply/projects/views/project.py +++ b/opentech/apply/projects/views/project.py @@ -68,7 +68,7 @@ from ..tables import ( ProjectsListTable ) -from .report import ReportingMixin +from .report import ReportingMixin, ReportFrequencyUpdate # APPROVAL VIEWS @@ -423,6 +423,7 @@ class AdminProjectDetailView( RemoveDocumentView, SelectDocumentView, SendForApprovalView, + ReportFrequencyUpdate, UpdateLeadView, UploadContractView, UploadDocumentView, diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py index 5b3d76ce8..d90767e37 100644 --- a/opentech/apply/projects/views/report.py +++ b/opentech/apply/projects/views/report.py @@ -13,10 +13,14 @@ from django.views.generic.detail import SingleObjectMixin from opentech.apply.activity.messaging import MESSAGES, messenger from opentech.apply.utils.storage import PrivateMediaView +from opentech.apply.utils.views import DelegatedViewMixin from opentech.apply.users.decorators import staff_required from ..models import Report, ReportConfig, ReportPrivateFiles -from ..forms import ReportEditForm +from ..forms import ( + ReportEditForm, + ReportFrequencyForm, +) class ReportingMixin: @@ -161,3 +165,33 @@ class ReportSkipView(SingleObjectMixin, View): related=report, ) return redirect(report.project.get_absolute_url()) + + +class ReportFrequencyUpdate(DelegatedViewMixin, UpdateView): + form_class = ReportFrequencyForm + context_name = 'update_frequency_form' + model = ReportConfig + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs.pop('user') + kwargs['instance'] = kwargs['instance'].report_config + return kwargs + + def get_form(self): + if self.get_parent_object().is_in_progress: + return super().get_form() + return None + + def form_valid(self, form): + config = form.instance + response = super().form_valid(form) + messenger( + MESSAGES.REPORT_FREQUENCY_CHANGED, + request=self.request, + user=self.request.user, + source=config.project, + related=config, + ) + + return response -- GitLab