diff --git a/opentech/apply/activity/messaging.py b/opentech/apply/activity/messaging.py index 57a31e798777408e27d46da929c214689c6ad09f..3c07ee7c97fa62d08f3ff061c909d1630bb3ecbf 100644 --- a/opentech/apply/activity/messaging.py +++ b/opentech/apply/activity/messaging.py @@ -64,6 +64,10 @@ 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', + MESSAGES.SKIPPED_REPORT: 'report', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'config', + MESSAGES.REPORT_NOTIFY: 'report', } @@ -134,14 +138,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 +242,9 @@ 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', + MESSAGES.SKIPPED_REPORT: 'handle_skipped_report', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'handle_report_frequency', } def recipients(self, message_type, **kwargs): @@ -338,6 +345,16 @@ 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" + else: + return "Marked a Report as required" + def send_message(self, message, user, source, sources, **kwargs): from .models import Activity visibility = kwargs.get('visibility', ALL) @@ -405,6 +422,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 +649,12 @@ 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', + MESSAGES.SKIPPED_REPORT: 'messages/email/report_skipped.html', + MESSAGES.REPORT_FREQUENCY_CHANGED: 'messages/email/report_frequency.html', + MESSAGES.REPORT_NOTIFY: 'messages/email/report_notify.html', } def get_subject(self, message_type, source): @@ -674,15 +696,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 +721,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 +744,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): @@ -770,7 +788,7 @@ class EmailAdapter(AdapterBase): return self.render_message('messages/email/partners_update_partner.html', **kwargs) def render_message(self, template, **kwargs): - return render_to_string(template, kwargs) + return render_to_string(template, kwargs, kwargs['request']) def send_message(self, message, source, subject, recipient, logs, **kwargs): try: @@ -803,6 +821,8 @@ class DjangoMessagesAdapter(AdapterBase): MESSAGES.BATCH_DETERMINATION_OUTCOME: 'batch_determinations', 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): @@ -819,6 +839,16 @@ 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}" + else: + return f"Successfully unskipped a Report for {report.start_date} to {report.end_date}" + def batch_transition(self, sources, transitions, **kwargs): base_message = 'Successfully updated:' transition = '{submission} [{old_display} → {new_display}].' diff --git a/opentech/apply/activity/migrations/0050_add_submit_report.py b/opentech/apply/activity/migrations/0050_add_submit_report.py new file mode 100644 index 0000000000000000000000000000000000000000..570a0e32ce1500f100c637294f1f60e126a8dc6f --- /dev/null +++ b/opentech/apply/activity/migrations/0050_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', '0049_auto_20191112_1227'), + ] + + 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/migrations/0051_add_report_skipping_activity.py b/opentech/apply/activity/migrations/0051_add_report_skipping_activity.py new file mode 100644 index 0000000000000000000000000000000000000000..f410ec1db27371659722f6f4e1a1acfbce4759d8 --- /dev/null +++ b/opentech/apply/activity/migrations/0051_add_report_skipping_activity.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-10-31 16:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0050_add_submit_report'), + ] + + 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')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/migrations/0052_report_frequency_change.py b/opentech/apply/activity/migrations/0052_report_frequency_change.py new file mode 100644 index 0000000000000000000000000000000000000000..e6db41b01524b9b90f92dfef51ccf72f896f1737 --- /dev/null +++ b/opentech/apply/activity/migrations/0052_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', '0051_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/migrations/0053_nullable_by_report_notify.py b/opentech/apply/activity/migrations/0053_nullable_by_report_notify.py new file mode 100644 index 0000000000000000000000000000000000000000..5f4facc46b4c414e93c1110eff4f17060d8c23e7 --- /dev/null +++ b/opentech/apply/activity/migrations/0053_nullable_by_report_notify.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.11 on 2019-11-05 16:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('activity', '0052_report_frequency_change'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + 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'), ('REPORT_NOTIFY', 'Report Notify')], max_length=50), + ), + ] diff --git a/opentech/apply/activity/models.py b/opentech/apply/activity/models.py index cf37ef0c242564a3c388305568f8686af32938c2..82b87a65445c2510d139a0696a9e6243a5ccd588 100644 --- a/opentech/apply/activity/models.py +++ b/opentech/apply/activity/models.py @@ -148,7 +148,7 @@ class Event(models.Model): when = models.DateTimeField(auto_now_add=True) type = models.CharField(choices=MESSAGES.choices(), max_length=50) - by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True) content_type = models.ForeignKey(ContentType, blank=True, null=True, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(blank=True, null=True) source = GenericForeignKey('content_type', 'object_id') diff --git a/opentech/apply/activity/options.py b/opentech/apply/activity/options.py index 7d2f9bb588d82ad1b32fe80a6a9602944adc9362..c6b54450a0f2574c210c78c1a020b0021c23d48d 100644 --- a/opentech/apply/activity/options.py +++ b/opentech/apply/activity/options.py @@ -42,6 +42,10 @@ 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' + SKIPPED_REPORT = 'Skipped Report' + REPORT_FREQUENCY_CHANGED = 'Report Frequency Changed' + REPORT_NOTIFY = 'Report Notify' @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 c00b187bf3c39013a3f70dd7c0b24660491bfd34..e35b4d1f7afda34896c70ed40c8f69e7666a6a05 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 368b14344ad427b9d478a2c02ed7821d938101a3..a1af1fa8eb92e691ab2fc406b03d713dbc1d7a1b 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_frequency.html b/opentech/apply/activity/templates/messages/email/report_frequency.html new file mode 100644 index 0000000000000000000000000000000000000000..093142d3267a783ae69915d7d0de2e99939c0dca --- /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/activity/templates/messages/email/report_notify.html b/opentech/apply/activity/templates/messages/email/report_notify.html new file mode 100644 index 0000000000000000000000000000000000000000..104285b756e07a53ac11b581c380d72ce4d4834c --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/report_notify.html @@ -0,0 +1,10 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} + +A report is due for {{ source.title }} on {{ report.end_date }}. + +More information can be found on the project page: +{{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} + +{% endblock %} diff --git a/opentech/apply/activity/templates/messages/email/report_skipped.html b/opentech/apply/activity/templates/messages/email/report_skipped.html new file mode 100644 index 0000000000000000000000000000000000000000..36584004a6a55ed10f4b7c587248fade799a29df --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/report_skipped.html @@ -0,0 +1,13 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} + +An {{ ORG_SHORT_NAME }} staff member has marked a report as {% if report.skipped %}no longer required{% else %}required{% endif %} for {{ source.title }} for period {{ report.end_date }} to {{ report.end_date }}. + +{% if not report.skipped %} +This report was previously not required. Please ensure you now complete the report. +{% endif %} + +Title: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} 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 0000000000000000000000000000000000000000..dc9869a83f952e92692ca2154a21535ae98265e5 --- /dev/null +++ b/opentech/apply/activity/templates/messages/email/report_submitted.html @@ -0,0 +1,10 @@ +{% extends "messages/email/applicant_base.html" %} + +{% block content %} +An {{ ORG_SHORT_NAME }} staff member has submitted a report for {{ source.title }} for period {{ report.end_date }} to {{ report.end_date }}. + +You can review the report here: {{ request.scheme }}://{{ request.get_host }}{{ report.get_absolute_url }} + +Project: {{ source.title }} +Link: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }} +{% endblock %} diff --git a/opentech/apply/dashboard/views.py b/opentech/apply/dashboard/views.py index 9b963a58f1b778eafe431a0da02875d55bd3a9d1..acfcb49296d9ffbfc4ddad2b5900102a53f5d551 100644 --- a/opentech/apply/dashboard/views.py +++ b/opentech/apply/dashboard/views.py @@ -342,7 +342,7 @@ class ApplicantDashboardView(MultiTableMixin, TemplateView): return context def get_active_project_data(self, user): - return Project.objects.filter(user=user).in_progress().for_table() + return Project.objects.filter(user=user).active().for_table() def get_active_submissions(self, user): active_subs = ApplicationSubmission.objects.filter( diff --git a/opentech/apply/determinations/forms.py b/opentech/apply/determinations/forms.py index 12a3417a0e07dd7b1252d175fffd0f0ade2ed77f..4a94b7b24c761ceb0943f8fd1ebf0852355f2a05 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/funds/tables.py b/opentech/apply/funds/tables.py index 90821692c36308900cd77695b5ccfbe873dc05b0..ff5ff55a2fa922443613fa9913a31f9d873503d4 100644 --- a/opentech/apply/funds/tables.py +++ b/opentech/apply/funds/tables.py @@ -288,7 +288,7 @@ class RoundsTable(tables.Table): class Meta: fields = ('title', 'fund', 'lead', 'start_date', 'end_date', 'progress') - attrs = {'class': 'all-rounds-table'} + attrs = {'class': 'responsive-table'} def render_lead(self, value): return format_html('<span>{}</span>', value) diff --git a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html index de0201ac2a9efe9ea276af1d57886261656c0462..051a657f3bde149a3b3faa40e0db67e3ab3d22da 100644 --- a/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html +++ b/opentech/apply/funds/templates/funds/includes/table_filter_and_search.html @@ -52,7 +52,7 @@ </div> -<div class="filters"> +<div class="filters {% if filter_classes %}{{filter_classes}}{% endif %}"> <div class="filters__header"> <button class="filters__button js-clear-filters">Clear</button> <div>Filter by</div> diff --git a/opentech/apply/funds/tests/factories/models.py b/opentech/apply/funds/tests/factories/models.py index 20fd24800615cf1fa837906d852bf1b63e402823..7ded87fb773e9d7d678dac3f91c19cd773177288 100644 --- a/opentech/apply/funds/tests/factories/models.py +++ b/opentech/apply/funds/tests/factories/models.py @@ -72,22 +72,12 @@ class AbstractApplicationFactory(wagtail_factories.PageFactory): workflow_stages = 1 title = factory.Faker('sentence') + parent = factory.SubFactory(ApplyHomePageFactory) # Will need to update how the stages are identified as Fund Page changes workflow_name = factory.LazyAttribute(lambda o: workflow_for_stages(o.workflow_stages)) approval_form = factory.SubFactory('opentech.apply.projects.tests.factories.ProjectApprovalFormFactory') - @factory.post_generation - def parent(self, create, extracted_parent, **parent_kwargs): - # THIS MUST BE THE FIRST POST GENERATION METHOD OR THE OBJECT WILL BE UNSAVED - if create: - if extracted_parent and parent_kwargs: - raise ValueError('Cant pass a parent instance and attributes') - - parent = extracted_parent or ApplyHomePageFactory(**parent_kwargs) - - parent.add_child(instance=self) - @factory.post_generation def forms(self, create, extracted, **kwargs): if create: @@ -152,12 +142,7 @@ class RoundFactory(wagtail_factories.PageFactory): start_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * n + 1)) end_date = factory.Sequence(lambda n: datetime.date.today() + datetime.timedelta(days=7 * (n + 1))) lead = factory.SubFactory(StaffFactory) - - @factory.post_generation - def parent(self, create, extracted_parent, **parent_kwargs): - if create: - parent = extracted_parent or FundTypeFactory(**parent_kwargs) - parent.add_child(instance=self) + parent = factory.SubFactory(FundTypeFactory) @factory.post_generation def forms(self, create, extracted, **kwargs): diff --git a/opentech/apply/home/factories.py b/opentech/apply/home/factories.py index 89628412d6bbb099533b39452a8c0ce1a54a037b..bd18111c0bcb127625d3f6ca1ba14dedf5741b8d 100644 --- a/opentech/apply/home/factories.py +++ b/opentech/apply/home/factories.py @@ -1,10 +1,17 @@ -import factory import wagtail_factories from .models import ApplyHomePage +class ApplySiteFactory(wagtail_factories.SiteFactory): + class Meta: + django_get_or_create = ('hostname',) + + class ApplyHomePageFactory(wagtail_factories.PageFactory): + title = "Apply Home" + slug = 'apply' + class Meta: model = ApplyHomePage @@ -15,14 +22,3 @@ class ApplyHomePageFactory(wagtail_factories.PageFactory): return model_class.objects.get(slug=kwargs['slug']) except model_class.DoesNotExist: return super()._create(model_class, *args, **kwargs) - - @factory.post_generation - def parent(self, create, extracted_parent, **parent_kwargs): - if create and not self.get_parent(): - root = ApplyHomePage.get_first_root_node() - root.add_child(instance=self) - - @factory.post_generation - def site(self, create, extracted_site, **site_kwargs): - if create: - wagtail_factories.SiteFactory(root_page=self, is_default_site=True) diff --git a/opentech/apply/projects/filters.py b/opentech/apply/projects/filters.py index 260cc0ec0cc8909f4af22a1f6db53b8ad4556cb2..8f793c80c529814f32a28177548c422543122ed4 100644 --- a/opentech/apply/projects/filters.py +++ b/opentech/apply/projects/filters.py @@ -12,7 +12,8 @@ from .models import ( PROJECT_STATUS_CHOICES, REQUEST_STATUS_CHOICES, PaymentRequest, - Project + Project, + Report, ) User = get_user_model() @@ -41,3 +42,40 @@ class ProjectListFilter(filters.FilterSet): class Meta: fields = ['status', 'lead', 'fund'] model = Project + + +class DateRangeInputWidget(filters.widgets.SuffixedMultiWidget): + template_name = 'application_projects/filters/widgets/date_range_input_widget.html' + suffixes = ['after', 'before'] + + def __init__(self, attrs=None): + widgets = (forms.DateInput, forms.DateInput) + super().__init__(widgets, attrs) + + def decompress(self, value): + if value: + return [value.start, value.stop] + return [None, None] + + +class ReportListFilter(filters.FilterSet): + reporting_period = filters.DateFromToRangeFilter( + label="Reporting Period", + method="filter_reporting_period", + widget=DateRangeInputWidget, + ) + submitted = filters.DateFromToRangeFilter(widget=DateRangeInputWidget) + + class Meta: + model = Report + fields = ['submitted'] + + def filter_reporting_period(self, queryset, name, value): + after, before = value.start, value.stop + q = {} + if after: + q['start__gte'] = after + if before: + q['end_date__lte'] = before + + return queryset.filter(**q) diff --git a/opentech/apply/projects/forms.py b/opentech/apply/projects/forms.py index 5dc566f88d8a51ea809e0e9e25289dc10699f2b1..eeeb245ba3c46e3b1e0d94de8e9902df6601fc66 100644 --- a/opentech/apply/projects/forms.py +++ b/opentech/apply/projects/forms.py @@ -2,14 +2,18 @@ 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 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 +28,11 @@ from .models import ( PacketFile, PaymentReceipt, PaymentRequest, - Project + Project, + Report, + ReportConfig, + ReportVersion, + ReportPrivateFiles, ) @@ -369,3 +377,138 @@ class UpdateProjectLeadForm(forms.ModelForm): lead_field.queryset = (lead_field.queryset.exclude(pk=self.instance.lead_id) .filter(qwargs) .distinct()) + + +class ReportEditForm(forms.ModelForm): + public_content = RichTextField( + help_text="This section of the report will be shared with the broader community." + ) + private_content = RichTextField( + help_text="This section of the report will be shared with staff only." + ) + file_list = forms.ModelMultipleChoiceField( + widget=forms.CheckboxSelectMultiple(attrs={'class': 'delete'}), + queryset=ReportPrivateFiles.objects.all(), + required=False, + label='Files' + ) + files = MultiFileField(required=False, label='') + + class Meta: + model = Report + fields = ( + 'public_content', + 'private_content', + 'file_list', + 'files', + ) + + def __init__(self, *args, user=None, initial={}, **kwargs): + self.report_files = initial.pop( + 'file_list', + ReportPrivateFiles.objects.none(), + ) + super().__init__(*args, initial=initial, **kwargs) + self.fields['file_list'].queryset = self.report_files + self.user = user + + def clean(self): + cleaned_data = super().clean() + public = cleaned_data['public_content'] + private = cleaned_data['private_content'] + if not private and not public: + missing_content = 'Must include either public or private content when submitting a report.' + self.add_error('public_content', missing_content) + self.add_error('private_content', missing_content) + return cleaned_data + + @transaction.atomic + def save(self, commit=True): + is_draft = 'save' in self.data + + version = ReportVersion.objects.create( + report=self.instance, + public_content=self.cleaned_data['public_content'], + private_content=self.cleaned_data['private_content'], + submitted=timezone.now(), + draft=is_draft, + author=self.user, + ) + + if is_draft: + self.instance.draft = version + else: + # If this is the first submission of the report we track that as the + # submitted date of the report + if not self.instance.submitted: + self.instance.submitted = version.submitted + self.instance.current = version + self.instance.draft = None + + instance = super().save(commit) + + removed_files = self.cleaned_data['file_list'] + ReportPrivateFiles.objects.bulk_create( + ReportPrivateFiles(report=version, document=file.document) + for file in self.report_files + if file not in removed_files + ) + + added_files = self.cleaned_data['files'] + if added_files: + ReportPrivateFiles.objects.bulk_create( + ReportPrivateFiles(report=version, document=file) + for file in added_files + ) + + 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/management/commands/notify_report_due.py b/opentech/apply/projects/management/commands/notify_report_due.py new file mode 100644 index 0000000000000000000000000000000000000000..7f0c290cf12f0ca31ba4817b99239ba525bd04e0 --- /dev/null +++ b/opentech/apply/projects/management/commands/notify_report_due.py @@ -0,0 +1,55 @@ +from dateutil.relativedelta import relativedelta + +from django.conf import settings +from django.contrib.messages.storage.fallback import FallbackStorage +from django.core.management.base import BaseCommand +from django.http import HttpRequest +from django.utils import timezone +from django.urls import set_urlconf +from opentech.apply.activity.messaging import MESSAGES, messenger +from opentech.apply.home.models import ApplyHomePage +from opentech.apply.projects.models import Project + + +class Command(BaseCommand): + help = 'Notify users that they have a report due soon' + + def add_arguments(self, parser): + parser.add_argument('days_before', type=int) + + def handle(self, *args, **options): + site = ApplyHomePage.objects.first().get_site() + set_urlconf('opentech.apply.urls') + + # Mock a HTTPRequest in order to pass the site settings into the + # templates + request = HttpRequest() + request.META['SERVER_NAME'] = site.hostname + request.META['SERVER_PORT'] = site.port + request.META[settings.SECURE_PROXY_SSL_HEADER] = 'https' + request.session = {} + request._messages = FallbackStorage(request) + + today = timezone.now().date() + due_date = today + relativedelta(days=options['days_before']) + for project in Project.objects.in_progress(): + next_report = project.report_config.current_due_report() + due_soon = next_report.end_date == due_date + not_notified_today = ( + not next_report.notified or + next_report.notified.date() != today + ) + if due_soon and not_notified_today: + messenger( + MESSAGES.REPORT_NOTIFY, + request=request, + user=None, + source=project, + related=next_report, + ) + # Notify about the due report + next_report.notified = timezone.now() + next_report.save() + self.stdout.write( + self.style.SUCCESS(f'Notified project: {project.id}') + ) 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 0000000000000000000000000000000000000000..de6630d4f6f93d448385fd369da2ba392633f44a --- /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.report_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 0000000000000000000000000000000000000000..92ba2db6c134131359e3551ac9a5eec4b251152a --- /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/migrations/0027_report_current.py b/opentech/apply/projects/migrations/0027_report_current.py new file mode 100644 index 0000000000000000000000000000000000000000..5337aa8ba960084866ad169ed9acc0625ea8061a --- /dev/null +++ b/opentech/apply/projects/migrations/0027_report_current.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.11 on 2019-10-30 14:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0026_data_contract_approved_date'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='current', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='live_for_report', to='application_projects.ReportVersion'), + ), + ] diff --git a/opentech/apply/projects/migrations/0028_report_draft.py b/opentech/apply/projects/migrations/0028_report_draft.py new file mode 100644 index 0000000000000000000000000000000000000000..8ff7c23512b5a5cdc28d6e4eabd0e2c75d3cc210 --- /dev/null +++ b/opentech/apply/projects/migrations/0028_report_draft.py @@ -0,0 +1,32 @@ +# Generated by Django 2.1.11 on 2019-10-31 02:30 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('application_projects', '0027_report_current'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='draft', + field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='draft_for_report', to='application_projects.ReportVersion'), + ), + migrations.AddField( + model_name='reportversion', + name='draft', + field=models.BooleanField(default=False), + preserve_default=False, + ), + migrations.AddField( + model_name='reportversion', + name='author', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reports', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/opentech/apply/projects/migrations/0029_report_submitted.py b/opentech/apply/projects/migrations/0029_report_submitted.py new file mode 100644 index 0000000000000000000000000000000000000000..635f0b796fed614a7adc5a0b34583c445df72221 --- /dev/null +++ b/opentech/apply/projects/migrations/0029_report_submitted.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-10-31 09:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0028_report_draft'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='submitted', + field=models.DateTimeField(null=True), + ), + ] diff --git a/opentech/apply/projects/migrations/0030_report_skipped.py b/opentech/apply/projects/migrations/0030_report_skipped.py new file mode 100644 index 0000000000000000000000000000000000000000..103f0df3f57e433fe568ed97770ba6c32085f531 --- /dev/null +++ b/opentech/apply/projects/migrations/0030_report_skipped.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-10-31 14:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0029_report_submitted'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='skipped', + field=models.BooleanField(default=False), + ), + ] diff --git a/opentech/apply/projects/migrations/0031_add_public_private_content.py b/opentech/apply/projects/migrations/0031_add_public_private_content.py new file mode 100644 index 0000000000000000000000000000000000000000..c4af728a8736d4504e83693a75dbd1ecfc99c172 --- /dev/null +++ b/opentech/apply/projects/migrations/0031_add_public_private_content.py @@ -0,0 +1,28 @@ +# Generated by Django 2.1.11 on 2019-10-31 22:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0030_report_skipped'), + ] + + operations = [ + migrations.RenameField( + model_name='reportversion', + old_name='content', + new_name='public_content', + ), + migrations.RemoveField( + model_name='report', + name='public', + ), + migrations.AddField( + model_name='reportversion', + name='private_content', + field=models.TextField(default=''), + preserve_default=False, + ), + ] diff --git a/opentech/apply/projects/migrations/0032_report_notified.py b/opentech/apply/projects/migrations/0032_report_notified.py new file mode 100644 index 0000000000000000000000000000000000000000..ec7ae7bf0c846eb290df9950923434cc01a237bc --- /dev/null +++ b/opentech/apply/projects/migrations/0032_report_notified.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.11 on 2019-11-05 15:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('application_projects', '0031_add_public_private_content'), + ] + + operations = [ + migrations.AddField( + model_name='report', + name='notified', + field=models.DateTimeField(null=True), + ), + ] diff --git a/opentech/apply/projects/models.py b/opentech/apply/projects/models.py index 7e2739bd15dbb0f3ac8783c77f3f7780c7278cab..87993c947a3acc4199eb10db0405248498040bc5 100644 --- a/opentech/apply/projects/models.py +++ b/opentech/apply/projects/models.py @@ -1,22 +1,36 @@ import collections +import datetime import decimal 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.humanize.templatetags.humanize import ordinal from django.contrib.postgres.fields import JSONField from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator from django.db import models -from django.db.models import F, Max, Sum, Value as V -from django.db.models.functions import Coalesce +from django.db.models import ( + Case, + F, + ExpressionWrapper, + Max, + OuterRef, + Q, + Subquery, + Sum, + Value as V, + When, +) +from django.db.models.functions import Cast, Coalesce from django.db.models.signals import post_delete from django.dispatch.dispatcher import receiver from django.urls import reverse from django.utils import timezone +from django.utils.functional import cached_property from django.utils.translation import ugettext as _ from wagtail.contrib.settings.models import BaseSetting, register_setting from wagtail.admin.edit_handlers import ( @@ -53,6 +67,10 @@ def receipt_path(instance, filename): return f'projects/{instance.payment_request.project_id}/payment_receipts/{filename}' +def report_path(instance, filename): + return f'reports/{instance.report.report_id}/version/{instance.report_id}/{filename}' + + class Approval(models.Model): project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="approvals") by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="approvals") @@ -79,6 +97,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() @@ -266,9 +285,16 @@ PROJECT_STATUS_CHOICES = [ class ProjectQuerySet(models.QuerySet): - def in_progress(self): + def active(self): + "Projects that are not finished" return self.exclude(status=COMPLETE) + def in_progress(self): + "Projects that users need to interact with, submitting reports or payment request" + return self.filter( + status__in=(IN_PROGRESS, CLOSING,) + ) + def complete(self): return self.filter(status=COMPLETE) @@ -294,8 +320,26 @@ class ProjectQuerySet(models.QuerySet): last_payment_request=Max('payment_requests__requested_at'), ) + def with_start_date(self): + return self.annotate( + start=Cast( + Subquery( + Contract.objects.filter( + project=OuterRef('pk'), + ).approved().order_by( + 'approved_at' + ).values('approved_at')[:1] + ), + models.DateField(), + ) + ) + def for_table(self): - return self.with_amount_paid().with_last_payment() + return self.with_amount_paid().with_last_payment().select_related( + 'report_config', + 'submission__page', + 'lead', + ) class Project(BaseStreamForm, AccessFormData, models.Model): @@ -384,6 +428,24 @@ 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() + + @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() @@ -527,3 +589,238 @@ 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 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 is_up_to_date(self): + return len(self.project.reports.to_do()) == 0 + + def outstanding_reports(self): + return len(self.project.reports.to_do()) + + def has_very_late_reports(self): + return self.project.reports.any_very_late() + + def past_due_reports(self): + return self.project.reports.to_do() + + 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: + return None + + today = timezone.now().date() + + last_report = self.last_report() + + 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 self.schedule_start and self.schedule_start >= today: + # Schedule changed since project inception + 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 + 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, + current__isnull=True, + 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 ReportQueryset(models.QuerySet): + def done(self): + return self.filter( + Q(current__isnull=False) | Q(skipped=True), + ) + + def to_do(self): + today = timezone.now().date() + return self.filter( + current__isnull=True, + skipped=False, + end_date__lt=today, + ).order_by('end_date') + + def any_very_late(self): + two_weeks_ago = timezone.now().date() - relativedelta(weeks=2) + return self.to_do().filter(end_date__lte=two_weeks_ago) + + def submitted(self): + return self.filter(current__isnull=False) + + def for_table(self): + return self.annotate( + last_end_date=Subquery( + Report.objects.filter( + project=OuterRef('project_id'), + end_date__lt=OuterRef('end_date') + ).values('end_date')[:1] + ), + project_start_date=Subquery( + Project.objects.filter( + pk=OuterRef('project_id'), + ).with_start_date().values('start')[:1] + ), + start=Case( + When( + last_end_date__isnull=False, + # Expression Wrapper doesn't cast the calculated object + # Use cast to get an actual date object + then=Cast( + ExpressionWrapper( + F('last_end_date') + datetime.timedelta(days=1), + output_field=models.DateTimeField(), + ), + models.DateField(), + ), + ), + default=F('project_start_date'), + output_field=models.DateField(), + ) + ) + + +class Report(models.Model): + skipped = models.BooleanField(default=False) + end_date = models.DateField() + project = models.ForeignKey("Project", on_delete=models.CASCADE, related_name="reports") + submitted = models.DateTimeField(null=True) + notified = models.DateTimeField(null=True) + current = models.OneToOneField( + "ReportVersion", + on_delete=models.CASCADE, + related_name='live_for_report', + null=True, + ) + draft = models.OneToOneField( + "ReportVersion", + on_delete=models.CASCADE, + related_name='draft_for_report', + null=True, + ) + + objects = ReportQueryset.as_manager() + + class Meta: + ordering = ('-end_date',) + + def get_absolute_url(self): + return reverse('apply:projects:reports:detail', kwargs={'pk': self.pk}) + + @property + def past_due(self): + return timezone.now().date() > self.end_date + + @property + def is_very_late(self): + two_weeks_ago = timezone.now().date() - relativedelta(weeks=2) + two_weeks_late = self.end_date < two_weeks_ago + not_submitted = not self.current + return not_submitted and two_weeks_late + + @property + def can_submit(self): + return self.start_date <= timezone.now().date() + + @property + def submitted_date(self): + if self.submitted: + return self.submitted.date() + + @cached_property + def start_date(self): + last_report = self.project.reports.filter(end_date__lt=self.end_date).first() + if last_report: + return last_report.end_date + relativedelta(days=1) + + return self.project.start_date + + +class ReportVersion(models.Model): + report = models.ForeignKey("Report", on_delete=models.CASCADE, related_name="versions") + submitted = models.DateTimeField() + public_content = models.TextField() + private_content = models.TextField() + draft = models.BooleanField() + author = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + related_name="reports", + null=True, + ) + + +class ReportPrivateFiles(models.Model): + report = models.ForeignKey("ReportVersion", on_delete=models.CASCADE, related_name="files") + document = models.FileField(upload_to=report_path, storage=PrivateStorage()) + + @property + def filename(self): + return os.path.basename(self.document.name) + + def __str__(self): + return self.filename + + def get_absolute_url(self): + return reverse('apply:projects:reports:document', kwargs={'pk': self.report.report_id, 'file_pk': self.pk}) diff --git a/opentech/apply/projects/tables.py b/opentech/apply/projects/tables.py index 1aaa9d67b08e956983f158be9d74fb0923e47e8a..dc07414b3c84cd08f2eedd791f63a043c9de6834 100644 --- a/opentech/apply/projects/tables.py +++ b/opentech/apply/projects/tables.py @@ -3,8 +3,9 @@ import textwrap import django_tables2 as tables from django.db.models import F, Sum from django.contrib.humanize.templatetags.humanize import intcomma +from django.utils.safestring import mark_safe -from .models import PaymentRequest, Project +from .models import PaymentRequest, Project, Report class BasePaymentRequestsTable(tables.Table): @@ -71,6 +72,7 @@ class BaseProjectsTable(tables.Table): ) status = tables.Column(verbose_name='Status', accessor='get_status_display', order_by=('status',)) fund = tables.Column(verbose_name='Fund', accessor='submission.page') + reporting = tables.Column(verbose_name='Reporting', accessor='pk') last_payment_request = tables.DateColumn() end_date = tables.DateColumn(verbose_name='End Date', accessor='proposed_end') fund_allocation = tables.Column(verbose_name='Fund Allocation', accessor='value') @@ -78,6 +80,21 @@ class BaseProjectsTable(tables.Table): def render_fund_allocation(self, record): return f'${intcomma(record.amount_paid)} / ${intcomma(record.value)}' + def render_reporting(self, record): + if not hasattr(record, 'report_config'): + return '-' + + if record.report_config.is_up_to_date(): + return 'Up to date' + + if record.report_config.has_very_late_reports(): + display = '<svg class="icon"><use xlink:href="#exclamation-point"></use></svg>' + else: + display = '' + + display += f'{ record.report_config.outstanding_reports() } outstanding' + return mark_safe(display) + class ProjectsDashboardTable(BaseProjectsTable): class Meta: @@ -85,12 +102,14 @@ class ProjectsDashboardTable(BaseProjectsTable): 'title', 'status', 'fund', + 'reporting', 'last_payment_request', 'end_date', 'fund_allocation', ] model = Project orderable = False + attrs = {'class': 'projects-table'} class ProjectsListTable(BaseProjectsTable): @@ -100,6 +119,7 @@ class ProjectsListTable(BaseProjectsTable): 'status', 'lead', 'fund', + 'reporting', 'last_payment_request', 'end_date', 'fund_allocation', @@ -107,6 +127,7 @@ class ProjectsListTable(BaseProjectsTable): model = Project orderable = True order_by = ('-end_date',) + attrs = {'class': 'projects-table'} def order_fund_allocation(self, qs, is_descending): direction = '-' if is_descending else '' @@ -119,3 +140,30 @@ class ProjectsListTable(BaseProjectsTable): def order_end_date(self, qs, desc): return qs.by_end_date(desc), True + + +class ReportListTable(tables.Table): + project = tables.LinkColumn( + 'funds:projects:reports:detail', + text=lambda r: textwrap.shorten(r.project.title, width=30, placeholder="..."), + args=[tables.utils.A('pk')], + ) + report_period = tables.Column(accessor='pk') + submitted = tables.DateColumn() + lead = tables.Column(accessor='project.lead') + + class Meta: + fields = [ + 'project', + 'submitted', + ] + sequence = [ + 'project', + 'report_period', + '...' + ] + model = Report + attrs = {'class': 'responsive-table'} + + def render_report_period(self, record): + return f"{record.start} to {record.end_date}" diff --git a/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html new file mode 100644 index 0000000000000000000000000000000000000000..d498285f5c11f080e261a510ff8e8d6ce2453eb8 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/filters/widgets/date_range_input_widget.html @@ -0,0 +1,4 @@ +{% include widget.subwidgets.0.template_name %} +<span>to:</span> +{% include widget.subwidgets.1.template_name %} + diff --git a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html index 940cd498aae764b52bcc889bcca71295a47cda28..565b75271eea8439a6a84b3705daba606bd32708 100644 --- a/opentech/apply/projects/templates/application_projects/includes/payment_requests.html +++ b/opentech/apply/projects/templates/application_projects/includes/payment_requests.html @@ -1,81 +1,81 @@ {% load payment_request_tools humanize %} -<div id="payment-requests" class="payment-block"> - <div class="payment-block__header"> - <p class="payment-block__title">Payment Requests</p> - <a class="payment-block__button button button--primary" +<div id="payment-requests" class="data-block"> + <div class="data-block__header"> + <p class="data-block__title">Payment Requests</p> + <a class="data-block__button button button--primary" href="{% url "apply:projects:request" pk=object.pk %}"> Add Request </a> </div> - - <table class="payment-block__table"> - <thead> - <tr> - <th class="payment-block__table-amount">Amount</th> - <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-date">From</th> - <th class="payment-block__table-date">To</th> - <th class="payment-block__table-update"></th> - </tr> - </thead> - <tbody> - {% for payment_request in object.payment_requests.not_rejected %} - <tr> - <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td> - <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> - <td><span class="payment-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td> - <td><span class="payment-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td> - <td> - <a href="{{ payment_request.get_absolute_url }}">View</a> - {% can_edit payment_request user as user_can_edit_request %} - {% if user_can_edit_request %} - <a href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}"> - Edit - </a> - {% endif %} - - {% can_delete payment_request user as user_can_delete_request %} - {% if user_can_delete_request %} - <a href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}"> - Delete - </a> - {% endif %} - </td> - </tr> - {% empty %} - <tr> - <td colspan="5">No active Payment Requests.</td> - </tr> - {% endfor %} - </tbody> - </table> - - {% if object.payment_requests.rejected %} - <p class="payment-block__rejected"> - <a class="payment-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a> - </p> - - <table class="payment-block__table is-hidden js-payment-block-rejected-table"> + <div class="data-block__body"> + <table class="data-block__table"> <thead> <tr> - <th class="payment-block__table-amount">Amount</th> - <th class="payment-block__table-status">Status</th> - <th class="payment-block__table-view"></th> + <th class="data-block__table-amount">Amount</th> + <th class="data-block__table-status">Status</th> + <th class="data-block__table-date">From</th> + <th class="data-block__table-date">To</th> + <th class="data-block__table-update"></th> </tr> </thead> <tbody> - {% for payment_request in object.payment_requests.rejected %} + {% for payment_request in object.payment_requests.not_rejected %} + <tr> + <td><span class="data-block__mobile-label">Amount: </span>${{ payment_request.value|intcomma }}</td> + <td><span class="data-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> + <td><span class="data-block__mobile-label">From: </span>{{ payment_request.date_from.date }}</td> + <td><span class="data-block__mobile-label">To: </span>{{ payment_request.date_to.date }}</td> + <td> + <a class="data-block__action-link" href="{{ payment_request.get_absolute_url }}">View</a> + {% can_edit payment_request user as user_can_edit_request %} + {% if user_can_edit_request %} + <a class="data-block__action-link" href="{% url "apply:projects:payments:edit" pk=payment_request.pk %}"> + Edit + </a> + {% endif %} + + {% can_delete payment_request user as user_can_delete_request %} + {% if user_can_delete_request %} + <a class="data-block__action-link" href="{% url 'apply:projects:payments:delete' pk=payment_request.pk %}"> + Delete + </a> + {% endif %} + </td> + </tr> + {% empty %} <tr> - <td><span class="payment-block__mobile-label">Amount: </span>${{ payment_request.value }}</td> - <td><span class="payment-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> - <td><a href="{{ payment_request.get_absolute_url }}">View</a></td> + <td colspan="5">No active Payment Requests.</td> </tr> {% endfor %} </tbody> </table> - {% endif %} + {% if object.payment_requests.rejected %} + <p class="data-block__rejected"> + <a class="data-block__rejected-link js-payment-block-rejected-link" href="#">Show rejected</a> + </p> + + <table class="data-block__table is-hidden js-payment-block-rejected-table"> + <thead> + <tr> + <th class="data-block__table-amount">Amount</th> + <th class="data-block__table-status">Status</th> + <th class="data-block__table-view"></th> + </tr> + </thead> + <tbody> + {% for payment_request in object.payment_requests.rejected %} + <tr> + <td><span class="data-block__mobile-label">Amount: </span>${{ payment_request.value }}</td> + <td><span class="data-block__mobile-label">Status: </span>{{ payment_request.get_status_display }}</td> + <td><a href="{{ payment_request.get_absolute_url }}">View</a></td> + </tr> + {% endfor %} + </tbody> + </table> + {% endif %} + </div> </div> {% for form in change_payment_request_status_forms %} diff --git a/opentech/apply/projects/templates/application_projects/includes/report_line.html b/opentech/apply/projects/templates/application_projects/includes/report_line.html new file mode 100644 index 0000000000000000000000000000000000000000..b1782b17e982d94167460efa970284d7b0d2a693 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/report_line.html @@ -0,0 +1,46 @@ +<li class="data-block__list-item"> + <div class="data-block__info"> + {% if current %} + The {% if report.can_submit %}current{% else %}next{% endif %} reporting period is + {% else %} + A report is due for the period + {% endif %} + <b>{{ report.start_date }}</b> to <b>{{ report.end_date }}</b> + {% if report.is_very_late %} + <svg class="icon data-block__icon"><use xlink:href="#exclamation-point"></use></svg> + {% endif %} + </div> + + <div class="data-block__links"> + {% if report.can_submit %} + <a + class="data-block__button button button--primary" + href="{% url "apply:projects:reports:edit" pk=report.pk %}" + > + {% if report.draft %}Continue Editing{% else %}Add Report{% endif %} + </a> + {% endif %} + + {% if request.user.is_apply_staff and report.can_submit %} + + <input data-fancybox data-src="#skip-report-{{report.id}}" type="button" value="Skip" class="btn data-block__action-link"></input> + + <!-- Skip report confirmation modal --> + <div class="modal" id="skip-report-{{report.id}}"> + <h4 class="modal__header-bar modal__header-bar--no-bottom-space">Skip report</h4> + <div class="modal__copy"> + <p>You're skipping the report for <b>{{report.start_date}}</b> – <b>{{report.end_date}}</b></p> + <p>This will result in a gap in reporting for the project. You can undo this at any time.</p> + </div> + <form action="{% url "apply:projects:reports:skip" pk=report.pk %}" method="post"> + {% csrf_token %} + <div class="modal__buttons"> + <input type="submit" value="Continue" class="button button--primary button--submit"></input> + <button data-fancybox-close class="button button--submit button--white">Cancel</button> + </div> + </form> + </div> + + {% endif %} + </div> +</li> diff --git a/opentech/apply/projects/templates/application_projects/includes/reports.html b/opentech/apply/projects/templates/application_projects/includes/reports.html new file mode 100644 index 0000000000000000000000000000000000000000..43e6b96f5af327adfdccabd000b8dedd3296a01d --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/includes/reports.html @@ -0,0 +1,106 @@ +<div class="wrapper wrapper--outer-space-large"> + <div class="data-block"> + <div class="data-block__header"> + <p class="data-block__title">Reporting</p> + </div> + <div class="data-block__body"> + <div class="data-block__card"> + <p class="data-block__card-title">Report frequency</p> + <p class="data-block__card-copy">{{ object.report_config.get_frequency_display }}</p> + {% if request.user.is_apply_staff %} + <p class="data-block__card-copy"> + <a data-fancybox data-src="#change-frequency" href="#" class="data-block__action-link">Change</a> + </p> + <!-- Change report frequency modal --> + <div class="modal" id="change-frequency"> + {{ report_data|json_script:"reportData" }} + <h4 class="modal__header-bar">Change reporting frequency</h4> + <div class="form__info-box"> + <p> + Next report will be due in + <b class="js-next-report-due-slot">(please choose the next report date)</b> + and the report period will be + <b class="js-report-period-start"></b> + to + <b class="js-report-period-end">(please choose the next report date)</b> + and then every + <b class="js-frequency-number-slot"></b> + <b class="js-frequency-period-slot"></b> + after until the project end date: + <span class="js-project-end-slot"></span>. + </p> + </div> + <p>Schedule reports every:</p> + {% include 'funds/includes/delegated_form_base.html' with form=update_frequency_form value='Continue' extra_classes="form--report-frequency" %} + </div> + {% endif %} + </div> + <ul class="data-block__list"> + {% for report in object.report_config.past_due_reports %} + {% include "application_projects/includes/report_line.html" with report=report %} + {% endfor %} + {% with next_report=object.report_config.current_due_report %} + {% include "application_projects/includes/report_line.html" with report=next_report current=True %} + {% endwith %} + </ul> + </div> + </div> +</div> + + +<div class="wrapper wrapper--outer-space-large"> + <div class="data-block"> + <div class="data-block__header"> + <p class="data-block__title">Past reports</p> + </div> + <div class="data-block__body"> + <table class="data-block__table js-past-reports-table"> + <thead> + <tr> + <th class="data-block__table-date">Period End</th> + <th class="data-block__table-date">Submitted</th> + <th class="data-block__table-date">Privacy</th> + <th class="data-block__table-update"></th> + </tr> + </thead> + <tbody> + {% for report in object.reports.done %} + <tr {% if forloop.counter > 8 %}class="is-hidden"{% endif %}> + <td> + <span class="data-block__mobile-label">Period End: </span>{{ report.end_date }} + </td> + <td> + <span class="data-block__mobile-label">Submitted: </span>{{ report.submitted_date|default:"Skipped" }} + </td> + <td> + <span class="data-block__mobile-label">Privacy: </span>{% if report.public %}Public{% else %}Private{% endif %} + </td> + <td class="data-block__links"> + <a class="data-block__action-link" href="{% url "apply:projects:reports:detail" pk=report.pk %}">View</a> + + {% if request.user.is_apply_staff %} + <a class="data-block__action-link" href="{% url "apply:projects:reports:edit" pk=report.pk %}">Edit</a> + {% if report.skipped %} + <form action="{% url "apply:projects:reports:skip" pk=report.pk %}" method="post"> + {% csrf_token %} + <input type="submit" value="Unskip" class="btn data-block__action-link"></input> + </form> + {% endif %} + {% endif %} + </td> + </tr> + {% empty %} + <tr> + <td colspan="4">No reports submitted</td> + </tr> + {% endfor %} + </tbody> + </table> + {% if object.reports.done.count > 8 %} + <p class="data-block__pagination"> + <a class="data-block__pagination-link js-data-block-pagination" href="#">Show more</a> + </p> + {% endif %} + </div> + </div> +</div> diff --git a/opentech/apply/projects/templates/application_projects/overview.html b/opentech/apply/projects/templates/application_projects/overview.html index 6adc7c31c7fa1a93580e53243aba9a11d2c182d5..e3159e8497eae29124e8a3b0b55e656e3d69955f 100644 --- a/opentech/apply/projects/templates/application_projects/overview.html +++ b/opentech/apply/projects/templates/application_projects/overview.html @@ -54,6 +54,19 @@ </div> {% endif %} + {% if reports.table.data %} + <div class="wrapper wrapper--bottom-space"> + + {% include "funds/includes/table_filter_and_search.html" with filter=reports.filterset filter_action=reports.url heading="Reports" %} + + {% render_table reports.table %} + + <div class="all-submissions-table__more"> + <a href="{{ reports.url }}">Show all</a> + </div> + + </div> + {% endif %} </div> {% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/project_detail.html b/opentech/apply/projects/templates/application_projects/project_detail.html index de8cabd3568a81024811c88e2f42cfca7060fe51..10a41bcb50f9673bb999a4f842c4b319c0608daa 100644 --- a/opentech/apply/projects/templates/application_projects/project_detail.html +++ b/opentech/apply/projects/templates/application_projects/project_detail.html @@ -131,6 +131,12 @@ </div> {% endif %} + {% if object.is_in_progress %} + <div class="wrapper wrapper--outer-space-large"> + {% include "application_projects/includes/reports.html" %} + </div> + {% endif %} + {% if not object.is_in_progress %} {% include "application_projects/includes/supporting_documents.html" %} {% endif %} @@ -263,5 +269,7 @@ <script src="{% static 'js/apply/toggle-payment-block.js' %}"></script> <script src="{% static 'js/apply/toggle-proposal-info.js' %}"></script> <script src="{% static 'js/apply/file-uploads.js' %}"></script> + <script src="{% static 'js/apply/past-reports-pagination.js' %}"></script> + <script src="{% static 'js/apply/report-calculator.js' %}"></script> <script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> {% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/report_detail.html b/opentech/apply/projects/templates/application_projects/report_detail.html new file mode 100644 index 0000000000000000000000000000000000000000..ccd9ae0c97906186788c668e2f9505fdeb6a79eb --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/report_detail.html @@ -0,0 +1,44 @@ +{% extends "base-apply.html" %} +{% load static bleach_tags %} + +{% block title %}Report | {{ object.project.title }}{% endblock %} +{% block body_class %}{% 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">{{ object.project.title }}</h2> + <h5 class="heading heading--no-margin">View report</h5> + </div> + </div> + + <div class="wrapper wrapper--form"> + {% if report.skipped %} + <h2>Report Skipped</h2> + {% else %} + <h3>Public</h3> + <div class="rich-text"> + {{ object.current.public_content|bleach|safe }} + </div> + + <h3>Private</h3> + <div class="rich-text"> + {{ object.current.private_content|bleach|safe }} + </div> + {% for file in object.current.files.all %} + {% if forloop.first %} + <h4>Files</h4> + <ul> + {% endif %} + + <li><a href="{{ file.get_absolute_url }}">{{ file.filename }}</a></li> + + {% if forloop.last %} + </ul> + {% endif %} + {% endfor %} + {% endif %} + </div> +{% endblock %} 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 0000000000000000000000000000000000000000..d161b86d5d75c19127145e09b1c8293f4c30a6b5 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/report_form.html @@ -0,0 +1,77 @@ +{% extends "base-apply.html" %} +{% load static %} + +{% block extra_css %} +{{ block.super }} +<link rel="stylesheet" href="{% static 'css/apply/fancybox.css' %}"> +{% endblock %} + +{% block title %}Edit 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">{{ object.project.title }}</h2> + <h5 class="heading heading--no-margin">Submit a report</h5> + </div> +</div> + +{% include "forms/includes/form_errors.html" with form=form %} + +<div class="wrapper wrapper--light-grey-bg wrapper--form"> + <div class="wrapper--sidebar--inner"> + + <div class="alert"> + <svg class="alert__icon"><use xlink:href="#exclamation-point"></use></svg> + <p class="alert__text">You are reporting for the period running from {{ report.start_date }} to {{ report.end_date }}</p> + </div> + + <form class="form" action="" method="post" enctype="multipart/form-data" novalidate> + {% csrf_token %} + {{ form.media }} + + {% for field in form %} + {% if field.field %} + {% include "forms/includes/field.html" %} + {% else %} + {{ field }} + {% endif %} + {% endfor %} + + <input type="submit" id="submit-report-form" class="is-hidden" /> + <input data-fancybox data-src="#save-report" class="button button--submit button--top-space button--white" type="button" value="Save" /> + <input data-fancybox data-src="#submit-report" class="button button--primary" type="button" value="Submit" /> + + <!-- Save report modal --> + <div class="modal" id="save-report"> + <h4 class="modal__header-bar">Save report</h4> + <div class="modal__copy"> + <p>Saving a draft means this report will be visible to you and staff from your project page.</p> + </div> + <div class="modal__buttons"> + <button data-fancybox-close class="button button--submit button--white">Cancel</button> + <label class="button button--submit button--top-space button--primary" for="submit-report-form" tabindex="0">Save</label> + </div> + </div> + + <!-- Submit report modal --> + <div class="modal" id="submit-report"> + <h4 class="modal__header-bar">Submit report</h4> + <p>Are you sure you want to submit your report?</p> + <div class="modal__buttons"> + <button data-fancybox-close class="button button--submit button--white">Cancel</button> + <label class="button button--submit button--top-space button--primary" for="submit-report-form" tabindex="0">Submit</label> + </div> + </div> + </form> + </div> +</div> +{% endblock %} + +{% block extra_js %} +<script src="//cdnjs.cloudflare.com/ajax/libs/fancybox/3.4.1/jquery.fancybox.min.js"></script> +<script src="{% static 'js/apply/list-input-files.js' %}"></script> +<script src="{% static 'js/apply/fancybox-global.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/templates/application_projects/report_list.html b/opentech/apply/projects/templates/application_projects/report_list.html new file mode 100644 index 0000000000000000000000000000000000000000..ab735a8fb348ec34f80ed3d9696d87f4788d7f00 --- /dev/null +++ b/opentech/apply/projects/templates/application_projects/report_list.html @@ -0,0 +1,35 @@ +{% extends "base-apply.html" %} + +{% load render_table from django_tables2 %} +{% load static %} + +{% block title %}Reports{% endblock %} + +{% block content %} +<div class="admin-bar"> + <div class="admin-bar__inner wrapper--search"> + {% block page_header %} + <h1 class="gamma heading heading--no-margin heading--bold">Submitted Reports</h1> + {% endblock %} + </div> +</div> + +<div class="wrapper wrapper--large wrapper--inner-space-medium"> + {% if table %} + {% include "funds/includes/table_filter_and_search.html" with filter_form=filter_form use_search=False filter_action=filter_action filter_classes="filters--dates" %} + {% render_table table %} + {% else %} + <p>No Reports Available</p> + {% endif %} +</div> + +{% endblock content %} + +{% block extra_css %} + {{ filter.form.media.css }} +{% endblock %} + +{% block extra_js %} + {{ filter.form.media.js }} + <script src="{% static 'js/apply/submission-filters.js' %}"></script> +{% endblock %} diff --git a/opentech/apply/projects/tests/factories.py b/opentech/apply/projects/tests/factories.py index 23a535ed867e3b44325ac24cb1b5d692c0f67c5b..05a63a64d916310fc0569e805e9c2b1d81e8e481 100644 --- a/opentech/apply/projects/tests/factories.py +++ b/opentech/apply/projects/tests/factories.py @@ -3,17 +3,23 @@ import json import factory import pytz +from dateutil.relativedelta import relativedelta from django.utils import timezone from opentech.apply.funds.tests.factories import ApplicationSubmissionFactory from opentech.apply.projects.models import ( + COMPLETE, Contract, DocumentCategory, + IN_PROGRESS, PacketFile, PaymentReceipt, PaymentRequest, Project, ProjectApprovalForm, + Report, + ReportConfig, + ReportVersion, ) from opentech.apply.stream_forms.testing.factories import FormDataFactory, FormFieldsBlockFactory from opentech.apply.users.tests.factories import StaffFactory, UserFactory @@ -93,11 +99,19 @@ class ProjectFactory(factory.DjangoModelFactory): in_approval = factory.Trait( is_locked=True, ) + in_progress = factory.Trait( + status=IN_PROGRESS, + ) + is_complete = factory.Trait( + status=COMPLETE, + ) 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 +151,67 @@ class PaymentReceiptFactory(factory.DjangoModelFactory): class Meta: model = PaymentReceipt + + +class ReportConfigFactory(factory.DjangoModelFactory): + project = factory.SubFactory( + "opentech.apply.projects.tests.factories.ApprovedProjectFactory", + report_config=None, + ) + + class Meta: + model = ReportConfig + django_get_or_create = ('project',) + + class Params: + weeks = factory.Trait( + frequency=ReportConfig.WEEK, + ) + + +class ReportVersionFactory(factory.DjangoModelFactory): + report = factory.SubFactory("opentech.apply.projects.tests.factories.ReportFactory") + submitted = factory.LazyFunction(timezone.now) + public_content = factory.Faker('paragraph') + private_content = factory.Faker('paragraph') + draft = True + + class Meta: + model = ReportVersion + + @factory.post_generation + def relate(obj, create, should_relate, **kwargs): + if not create: + return + + if should_relate: + if obj.draft: + obj.report.draft = obj + else: + obj.report.current = obj + obj.report.submitted = obj.submitted + obj.report.save() + + +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)) + ) + is_submitted = factory.Trait( + version=factory.RelatedFactory(ReportVersionFactory, 'report', draft=False, relate=True) + ) + is_draft = factory.Trait( + version=factory.RelatedFactory(ReportVersionFactory, 'report', relate=True), + ) + + +class ApprovedProjectFactory(ProjectFactory): + contract = factory.RelatedFactory(ContractFactory, 'project') + report_config = factory.RelatedFactory(ReportConfigFactory, 'project') diff --git a/opentech/apply/projects/tests/test_commands.py b/opentech/apply/projects/tests/test_commands.py new file mode 100644 index 0000000000000000000000000000000000000000..6d907ab46586d98fba5c78281fac8f401d5c3b32 --- /dev/null +++ b/opentech/apply/projects/tests/test_commands.py @@ -0,0 +1,63 @@ +from io import StringIO + +from dateutil.relativedelta import relativedelta + +from django.core.management import call_command +from django.test import override_settings, TestCase +from django.utils import timezone + +from opentech.apply.home.models import ApplyHomePage + +from .factories import ( + ProjectFactory, + ReportConfigFactory, + ReportFactory, +) + + +@override_settings(ROOT_URLCONF='opentech.apply.urls') +class TestNotifyReportDue(TestCase): + def test_notify_report_due_in_7_days(self): + in_a_week = timezone.now() + relativedelta(days=7) + ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True) + out = StringIO() + + with self.settings(ALLOWED_HOSTS=[ApplyHomePage.objects.first().get_site().hostname]): + call_command('notify_report_due', 7, stdout=out) + self.assertIn('Notified project', out.getvalue()) + + def test_dont_notify_report_due_in_7_days_already_submitted(self): + in_a_week = timezone.now() + relativedelta(days=7) + config = ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True) + ReportFactory( + project=config.project, + is_submitted=True, + end_date=config.schedule_start, + ) + out = StringIO() + call_command('notify_report_due', 7, stdout=out) + self.assertNotIn('Notified project', out.getvalue()) + + def test_dont_notify_already_notified(self): + in_a_week = timezone.now() + relativedelta(days=7) + config = ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True) + ReportFactory( + project=config.project, + end_date=config.schedule_start, + notified=timezone.now(), + ) + out = StringIO() + call_command('notify_report_due', 7, stdout=out) + self.assertNotIn('Notified project', out.getvalue()) + + def test_dont_notify_project_not_in_progress(self): + ProjectFactory() + out = StringIO() + call_command('notify_report_due', 7, stdout=out) + self.assertNotIn('Notified project', out.getvalue()) + + def test_dont_notify_project_complete(self): + ProjectFactory(is_complete=True) + out = StringIO() + call_command('notify_report_due', 7, stdout=out) + self.assertNotIn('Notified project', out.getvalue()) diff --git a/opentech/apply/projects/tests/test_models.py b/opentech/apply/projects/tests/test_models.py index a467f9f1dfa2af32f22ea50197d41a8e1da1d56a..3ba12a70e5cc743b25bb01a38b544ce42e1292fb 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,169 @@ 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 TestReportConfig(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() + # Separate day from month for case where start date + 1 month would exceed next month + # length (31st Oct to 30th Nov) + # combined => 31th + 1 month = 30th - 1 day = 29th (wrong) + # separate => 31th - 1 day = 30th + 1 month = 30th (correct) + next_due = report.project.start_date - relativedelta(days=1) + relativedelta(months=1) + self.assertEqual(Report.objects.count(), 1) + self.assertEqual(report.end_date, next_due) + + 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)) + + # Separate day from month for case where start date + 1 month would exceed next month + # length (31st Oct to 30th Nov) + # combined => 31th + 1 month = 30th - 1 day = 29th (wrong) + # separate => 31th - 1 day = 30th + 1 month = 30th (correct) + next_due = self.today - relativedelta(days=1) + relativedelta(months=1) + + report = config.current_due_report() + 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)) + + report = config.current_due_report() + self.assertEqual(Report.objects.count(), 2) + self.assertEqual(report.end_date, self.today + relativedelta(days=3)) + + def test_submitted_report_unaffected(self): + config = ReportConfigFactory() + report = ReportFactory(is_submitted=True, project=config.project, end_date=self.today + relativedelta(days=1)) + next_report = config.current_due_report() + self.assertNotEqual(report, next_report) + + def test_past_due(self): + report = ReportFactory(past_due=True) + config = report.project.report_config + self.assertQuerysetEqual(config.past_due_reports(), [report], transform=lambda x: x) + + def test_past_due_has_drafts(self): + report = ReportFactory(past_due=True, is_draft=True) + config = report.project.report_config + self.assertQuerysetEqual(config.past_due_reports(), [report], transform=lambda x: x) + + def test_past_due_no_submitted(self): + report = ReportFactory(is_submitted=True, past_due=True) + config = report.project.report_config + self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x) + + def test_past_due_no_future(self): + report = ReportFactory(end_date=self.today + relativedelta(days=1)) + config = report.project.report_config + self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x) + + def test_past_due_no_skipped(self): + report = ReportFactory(skipped=True, past_due=True) + config = report.project.report_config + self.assertQuerysetEqual(config.past_due_reports(), [], transform=lambda x: x) + + +class TestReport(TestCase): + @property + def today(self): + return timezone.now().date() + + def from_today(self, days): + return self.today + relativedelta(days=days) + + def test_not_late_if_one_ahead(self): + report = ReportFactory(end_date=self.from_today(-3)) + ReportFactory(project=report.project) + self.assertFalse(report.is_very_late) + + def test_late_if_two_weeks_behind(self): + report = ReportFactory(end_date=self.from_today(-15)) + self.assertTrue(report.is_very_late) + + def test_not_late_if_two_ahead_but_one_in_future(self): + report = ReportFactory(end_date=self.from_today(-3)) + ReportFactory(project=report.project) + ReportFactory(end_date=self.from_today(2), project=report.project) + self.assertFalse(report.is_very_late) + + def test_start_date(self): + yesterday = self.from_today(-1) + ReportFactory(end_date=yesterday) + report = ReportFactory(end_date=self.from_today(1)) + self.assertEqual(report.start_date, self.today) + + def test_start_date_with_submitted(self): + yesterday = self.from_today(-1) + ReportFactory(end_date=yesterday) + report = ReportFactory(end_date=self.from_today(1), is_submitted=True) + self.assertEqual(report.start_date, self.today) + + def test_queryset_done_includes_submitted(self): + report = ReportFactory(is_submitted=True) + self.assertQuerysetEqual(Report.objects.done(), [report], transform=lambda x: x) + + def test_queryset_done_includes_skipped(self): + report = ReportFactory(skipped=True) + self.assertQuerysetEqual(Report.objects.done(), [report], transform=lambda x: x) + + def test_queryset_done_doesnt_includes_draft(self): + ReportFactory(is_draft=True) + self.assertQuerysetEqual(Report.objects.done(), [], transform=lambda x: x) + + def test_queryset_done_doesnt_includes_to_do(self): + ReportFactory() + self.assertQuerysetEqual(Report.objects.done(), [], transform=lambda x: x) diff --git a/opentech/apply/projects/tests/test_views.py b/opentech/apply/projects/tests/test_views.py index 8d84549be4d9b8766df977d36034df4130f61501..a6364605ab896ae711313e0ee18970bdf2c39c6e 100644 --- a/opentech/apply/projects/tests/test_views.py +++ b/opentech/apply/projects/tests/test_views.py @@ -1,10 +1,12 @@ from decimal import Decimal from io import BytesIO +from dateutil.relativedelta import relativedelta from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied from django.test import RequestFactory, TestCase, override_settings from django.urls import reverse +from django.utils import timezone from opentech.apply.funds.tests.factories import LabSubmissionFactory from opentech.apply.users.tests.factories import ( @@ -33,7 +35,9 @@ from .factories import ( PacketFileFactory, PaymentReceiptFactory, PaymentRequestFactory, - ProjectFactory + ProjectFactory, + ReportFactory, + ReportVersionFactory, ) @@ -1234,3 +1238,254 @@ 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, {'public_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().public_content, 'Some text') + self.assertEqual(report.versions.first(), report.current) + self.assertEqual(report.current.author, self.user) + self.assertIsNone(report.draft) + + def test_submit_private_report(self): + report = ReportFactory() + response = self.post_page(report, {'private_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().private_content, 'Some text') + self.assertEqual(report.versions.first(), report.current) + self.assertEqual(report.current.author, self.user) + self.assertIsNone(report.draft) + + def test_cant_submit_blank_report(self): + report = ReportFactory() + response = self.post_page(report, {}) + report.refresh_from_db() + self.assertEqual(response.status_code, 200) + self.assertEqual(report.versions.count(), 0) + + def test_save_report_draft(self): + report = ReportFactory() + response = self.post_page(report, {'public_content': 'Some text', 'save': 'Save'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().public_content, 'Some text') + self.assertEqual(report.versions.first(), report.draft) + self.assertIsNone(report.current) + + def test_save_report_with_draft(self): + report = ReportFactory(is_draft=True) + self.assertEqual(report.versions.first(), report.draft) + response = self.post_page(report, {'public_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.last().public_content, 'Some text') + self.assertEqual(report.versions.last(), report.current) + self.assertIsNone(report.draft) + + def test_edit_submitted_report(self): + report = ReportFactory(is_submitted=True) + self.assertEqual(report.versions.first(), report.current) + response = self.post_page(report, {'public_content': 'Some text', 'save': ' Save'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.last().public_content, 'Some text') + self.assertEqual(report.versions.last(), report.draft) + self.assertEqual(report.versions.first(), report.current) + + def test_resubmit_submitted_report(self): + yesterday = timezone.now() - relativedelta(days=1) + version = ReportVersionFactory(submitted=yesterday) + report = version.report + report.current = version + report.submitted = version.submitted + report.save() + self.assertEqual(report.submitted, yesterday) + self.assertEqual(report.versions.first(), report.current) + response = self.post_page(report, {'public_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.last().public_content, 'Some text') + self.assertEqual(report.versions.last(), report.current) + self.assertIsNone(report.draft) + self.assertEqual(report.submitted.date(), yesterday.date()) + self.assertEqual(report.current.submitted.date(), timezone.now().date()) + + def test_cant_submit_future_report(self): + submitted_report = ReportFactory( + end_date=timezone.now() + relativedelta(days=1), + is_submitted=True, + ) + future_report = ReportFactory( + end_date=timezone.now() + relativedelta(days=3), + project=submitted_report.project, + ) + response = self.post_page(future_report, {'public_content': 'Some text'}) + self.assertEqual(response.status_code, 404) + + +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, {'public_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().public_content, 'Some text') + self.assertEqual(report.versions.first(), report.current) + self.assertEqual(report.current.author, self.user) + + def test_submit_private_report(self): + report = ReportFactory(project__user=self.user) + response = self.post_page(report, {'private_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().private_content, 'Some text') + self.assertEqual(report.versions.first(), report.current) + self.assertEqual(report.current.author, self.user) + self.assertIsNone(report.draft) + + def test_cant_submit_blank_report(self): + report = ReportFactory(project__user=self.user) + response = self.post_page(report, {}) + report.refresh_from_db() + self.assertEqual(response.status_code, 200) + self.assertEqual(report.versions.count(), 0) + + def test_save_report_draft(self): + report = ReportFactory(project__user=self.user) + response = self.post_page(report, {'public_content': 'Some text', 'save': 'Save'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.first().public_content, 'Some text') + self.assertEqual(report.versions.first(), report.draft) + self.assertIsNone(report.current) + + def test_save_report_with_draft(self): + report = ReportFactory(is_draft=True, project__user=self.user) + self.assertEqual(report.versions.first(), report.draft) + response = self.post_page(report, {'public_content': 'Some text'}) + report.refresh_from_db() + self.assertRedirects(response, self.absolute_url(report.project.get_absolute_url())) + self.assertEqual(report.versions.last().public_content, 'Some text') + self.assertEqual(report.versions.last(), report.current) + self.assertIsNone(report.draft) + + def test_cant_edit_submitted_report(self): + report = ReportFactory(is_submitted=True, project__user=self.user) + self.assertEqual(report.versions.first(), report.current) + response = self.post_page(report, {'public_content': 'Some text', 'save': ' Save'}) + self.assertEqual(response.status_code, 404) + + def test_cant_submit_other_report(self): + report = ReportFactory() + response = self.post_page(report, {'public_content': 'Some text'}) + self.assertEqual(response.status_code, 403) + + +class TestStaffReportDetail(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:reports:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can_access_submitted_report(self): + report = ReportFactory(is_submitted=True) + response = self.get_page(report) + self.assertEqual(response.status_code, 200) + + def test_can_access_skipped_report(self): + report = ReportFactory(skipped=True) + response = self.get_page(report) + self.assertEqual(response.status_code, 200) + + def test_cant_access_draft_report(self): + report = ReportFactory(is_draft=True) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) + + def test_cant_access_future_report(self): + report = ReportFactory(end_date=timezone.now() + relativedelta(days=1)) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) + + +class TestApplicantReportDetail(BaseViewTestCase): + base_view_name = 'detail' + url_name = 'funds:projects:reports:{}' + user_factory = StaffFactory + + def get_kwargs(self, instance): + return { + 'pk': instance.pk, + } + + def test_can_access_own_submitted_report(self): + report = ReportFactory(is_submitted=True, project__user=self.user) + response = self.get_page(report) + self.assertEqual(response.status_code, 200) + + def test_cant_access_own_draft_report(self): + report = ReportFactory(is_draft=True, project__user=self.user) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) + + def test_cant_access_own_future_report(self): + report = ReportFactory(end_date=timezone.now() + relativedelta(days=1), project__user=self.user) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) + + def test_cant_access_other_submitted_report(self): + report = ReportFactory(is_submitted=True) + response = self.get_page(report) + self.assertEqual(response.status_code, 200) + + def test_cant_access_other_draft_report(self): + report = ReportFactory(is_draft=True) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) + + def test_cant_access_other_future_report(self): + report = ReportFactory(end_date=timezone.now() + relativedelta(days=1)) + response = self.get_page(report) + self.assertEqual(response.status_code, 404) diff --git a/opentech/apply/projects/urls.py b/opentech/apply/projects/urls.py index 3a0e838a8a34a1748fac2a46f85fac1909015eb1..41d28497532b41fe68ee4e7d142d6ebc1ffb30a1 100644 --- a/opentech/apply/projects/urls.py +++ b/opentech/apply/projects/urls.py @@ -14,6 +14,11 @@ from .views import ( ProjectListView, ProjectOverviewView, ProjectPrivateMediaView, + ReportDetailView, + ReportListView, + ReportPrivateMedia, + ReportSkipView, + ReportUpdateView, ) app_name = 'projects' @@ -39,4 +44,13 @@ urlpatterns = [ path('documents/receipt/<int:file_pk>/', PaymentRequestPrivateMedia.as_view(), name="receipt"), ])), ], 'payments'))), + path('reports/', include(([ + path('', ReportListView.as_view(), name='all'), + path('<int:pk>/', include([ + path('', ReportDetailView.as_view(), name='detail'), + path('skip/', ReportSkipView.as_view(), name='skip'), + 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 fa45c29c176b0b33d5b01a6e2e926dc9f487fbc7..940f89395e695b1bb720a89a1430f497e9257bf9 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 88cf33d6b778495279bcb8380fd7741f2b75ef03..4f5c8966c8f37f084e6c9a0bf4f1fab306a54f46 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 @@ -37,6 +38,7 @@ from ..files import get_files from ..filters import ( PaymentRequestListFilter, ProjectListFilter, + ReportListFilter, ) from ..forms import ( ApproveContractForm, @@ -60,13 +62,17 @@ from ..models import ( Contract, PacketFile, PaymentRequest, - Project + Project, + Report, ) from ..tables import ( PaymentRequestsListTable, - ProjectsListTable + ProjectsListTable, + ReportListTable, ) +from .report import ReportingMixin, ReportFrequencyUpdate + # APPROVAL VIEWS @@ -319,6 +325,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 +404,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 @@ -419,6 +426,7 @@ class AdminProjectDetailView( RemoveDocumentView, SelectDocumentView, SendForApprovalView, + ReportFrequencyUpdate, UpdateLeadView, UploadContractView, UploadDocumentView, @@ -430,6 +438,12 @@ class AdminProjectDetailView( context = super().get_context_data(**kwargs) context['approvals'] = self.object.approvals.distinct('by') context['remaining_document_categories'] = list(self.object.get_missing_document_categories()) + + if self.object.is_in_progress: + context['report_data'] = { + 'startDate': self.object.report_config.current_due_report().start_date, + 'projectEndDate': self.object.end_date, + } return context @@ -587,13 +601,10 @@ class ProjectEditView(ViewDispatcher): @method_decorator(staff_required, name='dispatch') class ProjectListView(SingleTableMixin, FilterView): filterset_class = ProjectListFilter - model = Project + queryset = Project.objects.for_table() table_class = ProjectsListTable template_name = 'application_projects/project_list.html' - def get_queryset(self): - return Project.objects.for_table() - @method_decorator(staff_required, name='dispatch') class ProjectOverviewView(TemplateView): @@ -603,9 +614,18 @@ class ProjectOverviewView(TemplateView): context = super().get_context_data(**kwargs) context['projects'] = self.get_projects(self.request) context['payment_requests'] = self.get_payment_requests(self.request) + context['reports'] = self.get_reports(self.request) context['status_counts'] = self.get_status_counts() return context + def get_reports(self, request): + reports = Report.objects.for_table().submitted()[:10] + return { + 'filterset': ReportListFilter(request.GET or None, request=request, queryset=reports), + 'table': ReportListTable(reports, order_by=()), + 'url': reverse('apply:projects:reports:all'), + } + def get_payment_requests(self, request): payment_requests = PaymentRequest.objects.order_by('date_to')[:10] diff --git a/opentech/apply/projects/views/report.py b/opentech/apply/projects/views/report.py new file mode 100644 index 0000000000000000000000000000000000000000..b76779ef6c9b90295926a1325840ef6800f5e756 --- /dev/null +++ b/opentech/apply/projects/views/report.py @@ -0,0 +1,211 @@ +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import UserPassesTestMixin +from django.core.exceptions import PermissionDenied +from django.http import Http404 +from django.shortcuts import get_object_or_404, redirect +from django.utils.decorators import method_decorator +from django.views import View +from django.views.generic import ( + DetailView, + UpdateView, +) +from django.views.generic.detail import SingleObjectMixin +from django_filters.views import FilterView +from django_tables2 import SingleTableMixin + +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 ..filters import ReportListFilter +from ..forms import ( + ReportEditForm, + ReportFrequencyForm, +) +from ..models import Report, ReportConfig, ReportPrivateFiles +from ..tables import ReportListTable + + +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: + 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 ReportDetailView(ReportAccessMixin, DetailView): + model = Report + + def dispatch(self, *args, **kwargs): + report = self.get_object() + if not report.current and not report.skipped: + raise Http404 + return super().dispatch(*args, **kwargs) + + +@method_decorator(login_required, name='dispatch') +class ReportUpdateView(ReportAccessMixin, UpdateView): + form_class = ReportEditForm + model = Report + + def dispatch(self, *args, **kwargs): + report = self.get_object() + if not report.can_submit: + raise Http404 + if report.current and self.request.user.is_applicant: + raise Http404 + return super().dispatch(*args, **kwargs) + + def get_initial(self): + if self.object.draft: + current = self.object.draft + else: + current = self.object.current + + if current: + return { + 'public_content': current.public_content, + 'private_content': current.private_content, + 'file_list': current.files.all(), + } + + return {} + + def get_form_kwargs(self): + return { + 'user': self.request.user, + **super().get_form_kwargs(), + } + + def get_success_url(self): + return self.object.project.get_absolute_url() + + def form_valid(self, form): + response = super().form_valid(form) + + should_notify = True + if self.object.draft: + # It was a draft submission + should_notify = False + else: + if self.object.submitted != self.object.current.submitted: + # It was a staff edit - post submission + should_notify = False + + if should_notify: + 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) + file_pk = kwargs.get('file_pk') + self.document = get_object_or_404( + ReportPrivateFiles.objects, + report__report=self.report, + pk=file_pk + ) + + if not hasattr(self.document.report, 'live_for_report'): + # this is not a document in the live report + # send the user to the report page to see latest version + return redirect(self.report.get_absolute_url()) + + return super().dispatch(*args, **kwargs) + + def get_media(self, *args, **kwargs): + return self.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 + + +@method_decorator(staff_required, name='dispatch') +class ReportSkipView(SingleObjectMixin, View): + model = Report + + def post(self, *args, **kwargs): + report = self.get_object() + if not report.current: + report.skipped = not report.skipped + report.save() + messenger( + MESSAGES.SKIPPED_REPORT, + request=self.request, + user=self.request.user, + source=report.project, + related=report, + ) + return redirect(report.project.get_absolute_url()) + + +@method_decorator(staff_required, name='dispatch') +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 + + +@method_decorator(staff_required, name='dispatch') +class ReportListView(SingleTableMixin, FilterView): + queryset = Report.objects.submitted().for_table() + filterset_class = ReportListFilter + table_class = ReportListTable + template_name = 'application_projects/report_list.html' diff --git a/opentech/apply/stream_forms/testing/factories.py b/opentech/apply/stream_forms/testing/factories.py index 045438cefae3c3480053ff85c6c5ad82e803440e..0cf89a20b98bf1a940789ef23508e50b378b8fb5 100644 --- a/opentech/apply/stream_forms/testing/factories.py +++ b/opentech/apply/stream_forms/testing/factories.py @@ -212,7 +212,7 @@ class DropdownFieldBlockFactory(FormFieldBlockFactory): class UploadableMediaFactory(FormFieldBlockFactory): - default_value = factory.django.FileField + default_value = factory.django.FileField() @classmethod def make_answer(cls, params=None): @@ -220,12 +220,12 @@ class UploadableMediaFactory(FormFieldBlockFactory): params.setdefault('data', b'this is some content') if params.get('filename') is None: params['filename'] = 'example.pdf' - file_name, file = cls.default_value()._make_content(params) + file_name, file = cls.default_value._make_content(params) return SimpleUploadedFile(file_name, file.read()) class ImageFieldBlockFactory(UploadableMediaFactory): - default_value = factory.django.ImageField + default_value = factory.django.ImageField() class Meta: model = stream_blocks.ImageFieldBlock diff --git a/opentech/apply/utils/fields.py b/opentech/apply/utils/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..952f37088488f151cc3cdd0b3c19bfb71c58d6dc --- /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/javascript/apply/past-reports-pagination.js b/opentech/static_src/src/javascript/apply/past-reports-pagination.js new file mode 100644 index 0000000000000000000000000000000000000000..ef1a1b41a1b3409fdf08d0579697d3f644101c97 --- /dev/null +++ b/opentech/static_src/src/javascript/apply/past-reports-pagination.js @@ -0,0 +1,27 @@ +(function ($) { + + 'use strict'; + + function pastReportsPagination() { + $('.js-data-block-pagination').click((e) => { + e.preventDefault(); + showNextTen(); + }); + } + + function showNextTen() { + const [...nextTen] = $('.js-past-reports-table tr.is-hidden').slice(0, 10); + nextTen.forEach(item => item.classList.remove('is-hidden')); + checkRemaining(); + } + + function checkRemaining() { + const [...remaining] = $('.js-past-reports-table tr.is-hidden'); + if (remaining.length === 0) { + $('.js-data-block-pagination').addClass('is-hidden'); + } + } + + pastReportsPagination(); + +})(jQuery); diff --git a/opentech/static_src/src/javascript/apply/report-calculator.js b/opentech/static_src/src/javascript/apply/report-calculator.js new file mode 100644 index 0000000000000000000000000000000000000000..c3a038c3e5a2d7d247053086f4d78ed7150690ee --- /dev/null +++ b/opentech/static_src/src/javascript/apply/report-calculator.js @@ -0,0 +1,102 @@ +(function ($) { + + 'use strict'; + + const reportData = JSON.parse(document.getElementById('reportData').textContent); + + // Form inputs + const frequencyNumberInput = document.getElementById('id_occurrence'); + const frequencyPeriodSelect = document.getElementById('id_frequency'); + const startDateInput = document.getElementById('id_start'); + + // Form slots + const projectEndSlot = document.querySelector('.js-project-end-slot'); + const frequencyNumberSlot = document.querySelector('.js-frequency-number-slot'); + const frequencyPeriodSlot = document.querySelector('.js-frequency-period-slot'); + const periodStartSlot = document.querySelector('.js-report-period-start'); + const periodEndSlot = document.querySelector('.js-report-period-end'); + const nextReportDueSlot = document.querySelector('.js-next-report-due-slot'); + + function init() { + // Set on page load + setProjectEnd(); + setFrequency(); + setReportPeriodStart(); + + // Add event listeners + addFrequencyEvents(); + addReportPeriodEvents(); + } + + // Sets the project end date in the form info box + function setProjectEnd() { + projectEndSlot.innerHTML = reportData.projectEndDate; + } + + // Set the reporting frequency + function setFrequency() { + frequencyNumberSlot.innerHTML = frequencyNumberInput.value; + pluraliseTimePeriod(frequencyNumberInput.value); + } + + // Set the reporting period start date + function setReportPeriodStart() { + const startDate = new Date(reportData.startDate); + periodStartSlot.innerHTML = startDate.toISOString().slice(0, 10); + } + + function addReportPeriodEvents() { + startDateInput.oninput = e => { + // Update the reporting period end date (next report date) + periodEndSlot.innerHTML = e.target.value; + + // Update the reporting period range (next report date - today) + const daysDiff = dateDiffInDays(new Date(), new Date(e.target.value)); + const weeksAndDays = getWeeks(daysDiff); + const {weeks, days} = weeksAndDays; + const pluraliseWeeks = weeks === 1 ? '' : 's'; + const pluraliseDays = days === 1 ? '' : 's'; + + nextReportDueSlot.innerHTML = ` + ${weeks > 0 ? `${weeks} week${pluraliseWeeks}` : ''} ${days} day${pluraliseDays} + `; + }; + } + + // Update reporting frequency as the options are changed + function addFrequencyEvents() { + frequencyNumberInput.oninput = e => { + frequencyNumberSlot.innerHTML = e.target.value; + pluraliseTimePeriod(e.target.value); + }; + + frequencyPeriodSelect.onchange = e => { + frequencyPeriodSlot.innerHTML = `${e.target.value}`; + pluraliseTimePeriod(frequencyNumberInput.value); + }; + } + + function pluraliseTimePeriod(number) { + frequencyPeriodSlot.innerHTML = `${frequencyPeriodSelect.value}${Number(number) === 1 ? '' : 's'}`; + } + + // Get the number of days between two dates + function dateDiffInDays(startDate, EndDate) { + const msPerDay = 1000 * 60 * 60 * 24; + const utc1 = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + const utc2 = Date.UTC(EndDate.getFullYear(), EndDate.getMonth(), EndDate.getDate()); + + return Math.floor((utc2 - utc1) / msPerDay); + } + + // Convert days into weeks and days + function getWeeks(days) { + return { + weeks: Math.floor(days / 7), + days: days % 7 + }; + } + + init(); + +})(jQuery); 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 ef371d1a796b90145ab925a322066cb3ea3cf1c0..4004a9d6827f4fa5aebea800fcbe4d4a01cf1cee 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); + } + } + } diff --git a/opentech/static_src/src/sass/apply/components/_alert.scss b/opentech/static_src/src/sass/apply/components/_alert.scss new file mode 100644 index 0000000000000000000000000000000000000000..9c1997b5e6fe28000fd9986286f5cc3f51c73371 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_alert.scss @@ -0,0 +1,24 @@ +.alert { + border: 2px solid $color--lighter-blue; + padding: 1rem; + margin: 0 0 2rem; + background-color: $color--light-blue-90; + display: flex; + align-items: center; + + @include media-query(tablet-portrait) { + padding: 1.5rem; + } + + &__icon { + width: 25px; + height: 25px; + fill: $color--light-blue; + margin-right: .8rem; + } + + &__text { + margin: 0; + flex: 1; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_data-block.scss b/opentech/static_src/src/sass/apply/components/_data-block.scss new file mode 100644 index 0000000000000000000000000000000000000000..b7c4152f4603671613d9129d23bce68e10990364 --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_data-block.scss @@ -0,0 +1,256 @@ +.data-block { + $root: &; + margin-bottom: 1rem; + + &__header { + padding: 1rem; + background-color: $color--primary; + + @include media-query(mob-landscape) { + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + } + } + + &__body { + border: 1px solid $color--mid-grey; + border-top: 0; + padding: 1rem; + + @include media-query(mob-landscape) { + padding: 2rem; + } + } + + &__title { + font-size: map-get($font-sizes, delta); + margin: 0 0 1rem; + color: $color--white; + + @include media-query(mob-landscape) { + margin: 0; + } + } + + &__button { + padding: .7rem 1.2rem; + + #{$root}__header & { + background-color: $color--white; + color: $color--primary; + + &:hover, + &:focus { + background-color: $color--white; + } + } + + #{$root}__links & { + margin-right: 1rem; + + &:only-child { + margin-right: 0; + } + } + } + + &__status { + margin: 0; + + @include media-query(tablet-landscape) { + display: block; + font-weight: $weight--bold; + } + } + + &__pagination, + &__rejected { + text-align: center; + } + + &__pagination-link, + &__rejected-link { + font-weight: $weight--bold; + } + + &__mobile-label { + display: inline-block; + font-weight: $weight--bold; + white-space: pre; + + @include media-query(tablet-landscape) { + display: none; + } + } + + &__table { + thead { + display: none; + border-top: 2px solid $color--light-mid-grey; + + @include media-query(tablet-landscape) { + display: table-header-group; + } + + th { + color: $color--mid-dark-grey; + padding: 10px; + + @include media-query(tablet-landscape) { + text-align: left; + } + } + + tr { + border-color: $color--light-mid-grey; + } + } + + tbody { + font-size: map-get($font-sizes, zeta); + } + + tr { + border: 0; + border-bottom: 2px solid $color--light-grey; + + &:hover { + box-shadow: none; + } + + td { + padding: 0 0 .5rem; + word-break: break-word; + + @include media-query(tablet-landscape) { + padding: 1rem; + } + + &:first-child { + padding: 1rem 0 .5rem; + + @include media-query(tablet-landscape) { + padding: 1rem; + } + } + } + } + } + + &__table-amount { + width: 25%; + min-width: 90px; + } + + &__table-status { + min-width: 160px; + width: 25%; + } + + &__table-date { + min-width: 180px; + width: 25%; + } + + &__table-update { + min-width: 160px; + width: 20%; + + @include media-query(desktop) { + width: 30%; + } + } + + &__action-link { + font-size: map-get($font-sizes, zeta); + display: inline-block; + margin-right: 1rem; + text-decoration: underline; + color: $color--primary; + word-break: normal; + + &:last-child { + margin: 0; + } + } + + &__list-item { + border-bottom: 2px solid $color--light-grey; + padding: 1rem 0; + + @include media-query(tablet-landscape) { + display: flex; + justify-content: space-between; + align-items: center; + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + border-bottom: 0; + } + + &:only-child { + padding: 0; + } + } + + &__info { + margin: 0 1rem 1rem 0; + + @include media-query(tablet-landscape) { + margin: 0 1rem 0 0; + flex: 1; + } + } + + &__links { + display: flex; + align-items: center; + + @include media-query(tablet-landscape) { + justify-content: flex-end; + } + } + + &__icon { + width: 25px; + height: 25px; + fill: $color--tomato; + } + + &__card { + padding-bottom: 1rem; + position: relative; + margin-bottom: 2rem; + + &::after { + content: ''; + width: calc(100% + 2rem); + position: absolute; + height: 2px; + display: block; + background: $color--mid-grey; + left: -1rem; + bottom: 0; + + @include media-query(mob-landscape) { + width: calc(100% + 4rem); + left: -2rem; + } + } + } + + &__card-copy, + &__card-title { + margin: 0 0 .5rem; + } + + &__card-title { + font-weight: $weight--bold; + } +} diff --git a/opentech/static_src/src/sass/apply/components/_form.scss b/opentech/static_src/src/sass/apply/components/_form.scss index ca166d9d4360a96664a6082a35ea52de2de16359..896eaba00a0f3bb8334a0f0d672714a6b1a21487 100644 --- a/opentech/static_src/src/sass/apply/components/_form.scss +++ b/opentech/static_src/src/sass/apply/components/_form.scss @@ -43,6 +43,23 @@ } } + #{$root}--report-frequency & { + margin: 0; + + // Number input + &:nth-of-type(1) { + width: 20%; + display: inline-block; + margin-right: 1rem; + } + + // Frequency select + &:nth-of-type(2) { + width: 50%; + display: inline-block; + } + } + &--wrap { flex-wrap: wrap; } @@ -151,10 +168,20 @@ opacity: 1; transition-delay: $base-delay * 10; } + + .filters--dates & { + align-items: flex-end; + margin: 10px 0 30px; + padding: 0; + } } label { display: none; + + .filters--dates & { + display: block; + } } // so the form can be output in any tag @@ -178,6 +205,58 @@ margin: 0; } } + + > li { + padding: 0 1rem; + + @include media-query(tablet-landscape) { + padding: 0; + } + + // re-order from/to date inputs and text + .filters--dates & { + margin: 0 auto 1rem; + max-width: 320px; + + @include media-query(mob-landscape) { + display: flex; + max-width: 600px; + + @supports (display: grid) { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 5px; + } + } + + @include media-query(tablet-landscape) { + margin: 0 1rem 0 0; + max-width: initial; + } + + label { + @supports (display: grid) { + grid-column: 1; + grid-row: 1; + } + } + + input { + &:first-of-type { + @supports (display: grid) { + grid-column: 1; + } + } + } + + span { + @supports (display: grid) { + grid-column: 2; + grid-row: 1; + } + } + } + } } &__label { @@ -469,4 +548,13 @@ border: 1px solid $color--mid-grey; max-width: 410px; } + + &__info-box { + background-color: $color--light-blue-90; + padding: 1rem; + + p { + margin: 0; + } + } } diff --git a/opentech/static_src/src/sass/apply/components/_payment-block.scss b/opentech/static_src/src/sass/apply/components/_payment-block.scss deleted file mode 100644 index b6f48c039dfadd578cca09e899a9e7d37cf02b93..0000000000000000000000000000000000000000 --- a/opentech/static_src/src/sass/apply/components/_payment-block.scss +++ /dev/null @@ -1,152 +0,0 @@ -.payment-block { - border: 1px solid $color--mid-grey; - padding: 1rem; - margin-bottom: 1rem; - - @include media-query(mob-landscape) { - padding: 2rem; - } - - &__header { - margin-bottom: 1rem; - - @include media-query(tablet-portrait) { - margin-bottom: 1.5rem; - } - - @include media-query(mob-landscape) { - display: flex; - justify-content: space-between; - align-items: center; - } - } - - &__title { - font-size: map-get($font-sizes, delta); - margin: 0 0 1rem; - - @include media-query(mob-landscape) { - margin: 0; - } - } - - &__button { - padding: .7rem 1.2rem; - } - - &__status { - margin: 0; - - @include media-query(tablet-landscape) { - display: block; - font-weight: $weight--bold; - } - } - - &__rejected { - text-align: center; - } - - &__rejected-link { - font-weight: $weight--bold; - } - - &__mobile-label { - display: inline-block; - font-weight: $weight--bold; - white-space: pre; - - @include media-query(tablet-landscape) { - display: none; - } - } - - &__table { - thead { - display: none; - border-top: 2px solid $color--light-mid-grey; - - @include media-query(tablet-landscape) { - display: table-header-group; - } - - th { - color: $color--mid-dark-grey; - padding: 10px; - - @include media-query(tablet-landscape) { - text-align: left; - } - } - - tr { - border-color: $color--light-mid-grey; - } - } - - tbody { - font-size: map-get($font-sizes, zeta); - - a { - text-decoration: underline; - } - } - - tr { - border: 0; - border-bottom: 2px solid $color--light-grey; - - &:hover { - box-shadow: none; - } - - td { - padding: 0 0 .5rem; - word-break: break-word; - - &:first-child { - padding: 1rem 0 .5rem; - - @include media-query(tablet-landscape) { - padding: 1rem; - } - } - - &:last-child { - padding: 0 0 1rem; - display: flex; - flex-wrap: wrap; - - & > * { - flex: 1 1 55px; - max-width: 55px; - } - } - - @include media-query(tablet-landscape) { - padding: 1rem; - } - } - } - } - - &__table-amount { - width: 25%; - min-width: 90px; - } - - &__table-status { - min-width: 160px; - width: 25%; - } - - &__table-date { - min-width: 180px; - width: 25%; - } - - &__table-update { - min-width: 160px; - width: 25%; - } -} diff --git a/opentech/static_src/src/sass/apply/components/_projects-table.scss b/opentech/static_src/src/sass/apply/components/_projects-table.scss new file mode 100644 index 0000000000000000000000000000000000000000..c4ba256df7f6bdd460bb540d81328a6c93df87ef --- /dev/null +++ b/opentech/static_src/src/sass/apply/components/_projects-table.scss @@ -0,0 +1,10 @@ +.projects-table { + .reporting { + .icon { + margin-right: 0.3rem; + width: 25px; + height: 25px; + fill: $color--tomato; + } + } +} diff --git a/opentech/static_src/src/sass/apply/components/_all-rounds-table.scss b/opentech/static_src/src/sass/apply/components/_responsive-table.scss similarity index 94% rename from opentech/static_src/src/sass/apply/components/_all-rounds-table.scss rename to opentech/static_src/src/sass/apply/components/_responsive-table.scss index 3be90e1d1aeed7bea486547e45a16653f0d757c5..ea14a560a1783bd3313c4b242a2ff86acbbf919a 100644 --- a/opentech/static_src/src/sass/apply/components/_all-rounds-table.scss +++ b/opentech/static_src/src/sass/apply/components/_responsive-table.scss @@ -1,4 +1,4 @@ -.all-rounds-table { +.responsive-table { @include table-ordering-styles; thead { diff --git a/opentech/static_src/src/sass/apply/main.scss b/opentech/static_src/src/sass/apply/main.scss index d2cd0d97d0c70c71b9e56bbda14aba977e6c7fb4..cbba6b86e8c3fccff9dfdc25cd02e8b216e030d5 100644 --- a/opentech/static_src/src/sass/apply/main.scss +++ b/opentech/static_src/src/sass/apply/main.scss @@ -8,8 +8,8 @@ @import 'base/typography'; // Components +@import 'components/alert'; @import 'components/all-submissions-table'; -@import 'components/all-rounds-table'; @import 'components/admin-bar'; @import 'components/actions-bar'; @import 'components/card'; @@ -38,7 +38,9 @@ @import 'components/nav'; @import 'components/pagination'; @import 'components/profile'; +@import 'components/projects-table'; @import 'components/related-sidebar'; +@import 'components/responsive-table'; @import 'components/reviewer-dash-box'; @import 'components/reviews-list'; @import 'components/reviews-summary'; @@ -58,7 +60,7 @@ @import 'components/stat-block'; @import 'components/docs-block'; @import 'components/funding-block'; -@import 'components/payment-block'; +@import 'components/data-block'; @import 'components/invoice-block'; // Layout diff --git a/requirements-dev.txt b/requirements-dev.txt index bd3c6039bb60213e542d2d10598a6ecfc7cf9209..2af17c454bfd0a30349bd304b59575a3b5413044 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,10 +1,10 @@ -r requirements.txt django-debug-toolbar==2.0 -factory_boy==2.9.2 +factory_boy==2.12 Faker==1.0.8 # Pinning to avoid API changes to pydecimal in 0.x - could go higher in future flake8==3.7.9 responses==0.10.6 stellar==0.4.5 -wagtail-factories==1.1.0 +wagtail-factories==2.0.0 Werkzeug==0.15.3