diff --git a/hypha/apply/activity/adapters/activity_feed.py b/hypha/apply/activity/adapters/activity_feed.py index 844818390a..bc1abce63e 100644 --- a/hypha/apply/activity/adapters/activity_feed.py +++ b/hypha/apply/activity/adapters/activity_feed.py @@ -65,6 +65,7 @@ class ActivityAdapter(AdapterBase): MESSAGES.UPDATE_INVOICE_STATUS: "handle_update_invoice_status", MESSAGES.CREATE_INVOICE: _("Invoice added"), MESSAGES.SUBMIT_REPORT: _("Submitted a report"), + MESSAGES.DELETE_REPORT: _("deleted a report"), MESSAGES.SKIPPED_REPORT: "handle_skipped_report", MESSAGES.REPORT_FREQUENCY_CHANGED: "handle_report_frequency", MESSAGES.DISABLED_REPORTING: _("disabled reporting"), @@ -112,6 +113,7 @@ def extra_kwargs(self, message_type, source, sources, **kwargs): MESSAGES.SUBMIT_CONTRACT_DOCUMENTS, MESSAGES.DELETE_INVOICE, MESSAGES.CREATE_INVOICE, + MESSAGES.DELETE_REPORT, ]: return {"visibility": APPLICANT} diff --git a/hypha/apply/activity/adapters/base.py b/hypha/apply/activity/adapters/base.py index c782c4ff4b..429428b643 100644 --- a/hypha/apply/activity/adapters/base.py +++ b/hypha/apply/activity/adapters/base.py @@ -33,6 +33,7 @@ MESSAGES.DELETE_INVOICE: "invoice", MESSAGES.UPDATE_INVOICE: "invoice", MESSAGES.SUBMIT_REPORT: "report", + MESSAGES.DELETE_REPORT: "report", MESSAGES.SKIPPED_REPORT: "report", MESSAGES.REPORT_FREQUENCY_CHANGED: "config", MESSAGES.REPORT_NOTIFY: "report", diff --git a/hypha/apply/activity/adapters/slack.py b/hypha/apply/activity/adapters/slack.py index 7c254d083e..3bdbe6afed 100644 --- a/hypha/apply/activity/adapters/slack.py +++ b/hypha/apply/activity/adapters/slack.py @@ -123,6 +123,9 @@ class SlackAdapter(AdapterBase): MESSAGES.SUBMIT_REPORT: _( "{user} has submitted a report for <{link}|{source.title}>" ), + MESSAGES.DELETE_REPORT: _( + "{user} has deleted a report for <{link}|{source.title}>" + ), MESSAGES.BATCH_DELETE_SUBMISSION: "handle_batch_delete_submission", MESSAGES.BATCH_ANONYMIZE_SUBMISSION: "handle_batch_anonymize_submission", MESSAGES.STAFF_ACCOUNT_CREATED: _( diff --git a/hypha/apply/activity/migrations/0094_alter_event_type.py b/hypha/apply/activity/migrations/0094_alter_event_type.py new file mode 100644 index 0000000000..7723cd9f35 --- /dev/null +++ b/hypha/apply/activity/migrations/0094_alter_event_type.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.15 on 2026-06-20 13:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0093_alter_event_type"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="type", + field=models.CharField( + choices=[ + ("UPDATE_LEAD", "updated lead"), + ("BATCH_UPDATE_LEAD", "batch updated lead"), + ("EDIT_SUBMISSION", "edited submission"), + ("APPLICANT_EDIT", "edited applicant"), + ("NEW_SUBMISSION", "submitted new submission"), + ("DRAFT_SUBMISSION", "submitted new draft submission"), + ("SCREENING", "screened"), + ("TRANSITION", "transitioned"), + ("BATCH_TRANSITION", "batch transitioned"), + ("DETERMINATION_OUTCOME", "sent determination outcome"), + ("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"), + ("INVITED_TO_PROPOSAL", "invited to proposal"), + ("REVIEWERS_UPDATED", "updated reviewers"), + ("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"), + ("READY_FOR_REVIEW", "marked ready for review"), + ("BATCH_READY_FOR_REVIEW", "marked batch ready for review"), + ("NEW_REVIEW", "added new review"), + ("COMMENT", "added comment"), + ("PROPOSAL_SUBMITTED", "submitted proposal"), + ("OPENED_SEALED", "opened sealed submission"), + ("REVIEW_OPINION", "reviewed opinion"), + ("DELETE_SUBMISSION", "deleted submission"), + ("ANONYMIZE_SUBMISSION", "anonymized submission"), + ("DELETE_REVIEW", "deleted review"), + ("DELETE_REVIEW_OPINION", "deleted review opinion"), + ("CREATED_PROJECT", "created project"), + ("UPDATE_PROJECT_LEAD", "updated project lead"), + ("UPDATE_PROJECT_TITLE", "updated project title"), + ("EDIT_REVIEW", "edited review"), + ("SEND_FOR_APPROVAL", "sent for approval"), + ("APPROVE_PROJECT", "approved project"), + ("ASSIGN_PAF_APPROVER", "assign project form approver"), + ("APPROVE_PAF", "approved project form"), + ("PROJECT_TRANSITION", "transitioned project"), + ("REQUEST_PROJECT_CHANGE", "requested project change"), + ("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"), + ("UPLOAD_DOCUMENT", "uploaded document to project"), + ("UPLOAD_CONTRACT", "uploaded contract to project"), + ("APPROVE_CONTRACT", "approved contract"), + ("CREATE_INVOICE", "created invoice for project"), + ("UPDATE_INVOICE_STATUS", "updated invoice status"), + ("APPROVE_INVOICE", "approve invoice"), + ("DELETE_INVOICE", "deleted invoice"), + ("SENT_TO_COMPLIANCE", "sent project to compliance"), + ("UPDATE_INVOICE", "updated invoice"), + ("SUBMIT_REPORT", "submitted report"), + ("DELETE_REPORT", "deleted report"), + ("SKIPPED_REPORT", "skipped report"), + ("REPORT_FREQUENCY_CHANGED", "changed report frequency"), + ("DISABLED_REPORTING", "disabled reporting"), + ("REPORT_NOTIFY", "notified report"), + ("REVIEW_REMINDER", "reminder to review"), + ("BATCH_DELETE_SUBMISSION", "batch deleted submissions"), + ("BATCH_ANONYMIZE_SUBMISSION", "batch anonymized submissions"), + ("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"), + ("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"), + ("STAFF_ACCOUNT_CREATED", "created new account"), + ("STAFF_ACCOUNT_EDITED", "edited account"), + ("ARCHIVE_SUBMISSION", "archived submission"), + ("UNARCHIVE_SUBMISSION", "unarchived submission"), + ("REMOVE_TASK", "remove task"), + ("INVITE_COAPPLICANT", "invite co-applicant"), + ("UPDATE_AUTHOR", "updated author"), + ], + max_length=50, + verbose_name="verb", + ), + ), + ] diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index 81cb7a9efc..b14da216f6 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -59,6 +59,7 @@ class MESSAGES(TextChoices): SENT_TO_COMPLIANCE = "SENT_TO_COMPLIANCE", _("sent project to compliance") UPDATE_INVOICE = "UPDATE_INVOICE", _("updated invoice") SUBMIT_REPORT = "SUBMIT_REPORT", _("submitted report") + DELETE_REPORT = "DELETE_REPORT", _("deleted report") SKIPPED_REPORT = "SKIPPED_REPORT", _("skipped report") REPORT_FREQUENCY_CHANGED = "REPORT_FREQUENCY_CHANGED", _("changed report frequency") DISABLED_REPORTING = "DISABLED_REPORTING", _("disabled reporting") diff --git a/hypha/apply/projects/reports/forms.py b/hypha/apply/projects/reports/forms.py index 8dd8766029..dde40ccb1e 100644 --- a/hypha/apply/projects/reports/forms.py +++ b/hypha/apply/projects/reports/forms.py @@ -96,6 +96,42 @@ def save(self, commit=True, form_fields=dict): return instance +class ReportAddDateForm(forms.Form): + end_date = forms.DateField( + label=_("Report end date"), + widget=forms.DateInput(attrs={"type": "date"}), + ) + + def __init__(self, *args, project=None, **kwargs): + super().__init__(*args, **kwargs) + self.project = project + + def clean_end_date(self): + end_date = self.cleaned_data["end_date"] + if self.project and self.project.reports.filter(end_date=end_date).exists(): + raise forms.ValidationError(_("A report for this date already exists.")) + return end_date + + +class ReportEditDueDateForm(forms.ModelForm): + class Meta: + model = Report + fields = ["end_date"] + labels = {"end_date": _("Report due date")} + widgets = {"end_date": forms.DateInput(attrs={"type": "date"})} + + def clean_end_date(self): + end_date = self.cleaned_data["end_date"] + if ( + self.instance.pk + and self.instance.project.reports.filter(end_date=end_date) + .exclude(pk=self.instance.pk) + .exists() + ): + raise forms.ValidationError(_("A report for this date already exists.")) + return end_date + + class ReportFrequencyForm(forms.ModelForm): start = forms.DateField(label=_("Report on:"), required=False) diff --git a/hypha/apply/projects/reports/management/commands/notify_report_due.py b/hypha/apply/projects/reports/management/commands/notify_report_due.py index ae8e27a5fd..41b3786580 100644 --- a/hypha/apply/projects/reports/management/commands/notify_report_due.py +++ b/hypha/apply/projects/reports/management/commands/notify_report_due.py @@ -46,7 +46,7 @@ def handle(self, *args, **options): due_date = today + relativedelta(days=delta) for project in Project.objects.in_progress(): - next_report = project.report_config.current_due_report() + next_report = project.report_config.ensure_due_report() if not next_report: continue diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index 9ac4c372ef..c0ed2e90e2 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -5,7 +5,7 @@ from django.apps import apps from django.conf import settings from django.contrib.humanize.templatetags.humanize import ordinal -from django.db import models +from django.db import models, transaction from django.db.models import Case, ExpressionWrapper, F, OuterRef, Q, Subquery, When from django.db.models.functions import Cast from django.urls import reverse @@ -326,6 +326,14 @@ def has_very_late_reports(self): def past_due_reports(self): return self.project.reports.to_do() + def future_due_reports(self): + today = timezone.now().date() + return self.project.reports.filter( + current__isnull=True, + skipped=False, + end_date__gte=today, + ).order_by("end_date") + def last_report(self): today = timezone.now().date() # Get the most recent report that was either: @@ -336,7 +344,15 @@ def last_report(self): Q(end_date__lt=today) | Q(skipped=True) | Q(submitted__isnull=False) ).first() - def current_due_report(self): + def ensure_due_report(self): + """Create the next scheduled report row if none exists yet. + + Computes what date the next report should be due based on the reporting + schedule, then finds or creates a pending report for that date. Safe to + call concurrently — uses SELECT FOR UPDATE to prevent duplicate creation. + + Returns the pending report, or None if no report is required. + """ if self.disable_reporting: return None @@ -377,15 +393,49 @@ def current_due_report(self): today, ) - report, _ = self.project.reports.update_or_create( - project=self.project, - current__isnull=True, - skipped=False, - end_date__gte=today, - defaults={"end_date": next_due_date}, - ) + with transaction.atomic(): + # Lock this ReportConfig row so concurrent calls wait rather than + # both finding no report and each creating one. + ReportConfig.objects.select_for_update().get(pk=self.pk) + + due_reports = self.project.reports.filter( + current__isnull=True, + skipped=False, + end_date__gte=today, + ) + + report = due_reports.order_by("end_date").first() + if report is None: + report = self.project.reports.create( + project=self.project, + end_date=next_due_date, + ) + return report + def current_due_report(self): + """Return the earliest pending future report without creating one. + + Use ensure_due_report() when the scheduled report row must exist + (e.g. on page load or in the notification command). + """ + if self.disable_reporting: + return None + + if not self.project.proposed_start: + return None + + today = timezone.now().date() + return ( + self.project.reports.filter( + current__isnull=True, + skipped=False, + end_date__gte=today, + ) + .order_by("end_date") + .first() + ) + def current_report(self): """This is different from current_due_report as it will return a completed report if that one is the current one.""" diff --git a/hypha/apply/projects/reports/templates/reports/includes/report_line.html b/hypha/apply/projects/reports/templates/reports/includes/report_line.html index 43531c54a6..56ec2ef78d 100644 --- a/hypha/apply/projects/reports/templates/reports/includes/report_line.html +++ b/hypha/apply/projects/reports/templates/reports/includes/report_line.html @@ -1,37 +1,54 @@ {% load i18n heroicons %}
  • -

    - - {% if current %} - {% trans "The" %} {% if report.can_submit %}{% trans "current" %}{% else %}{% trans "next" %}{% endif %} {% trans "reporting period is" %} - {% else %} - {% trans "A report is due for the period" %} +

    + {{ report.end_date }} + {% if request.user.is_apply_staff %} + {% trans "edit" %} {% endif %} - {{ report.start_date }} {% trans "to" %} {{ report.end_date }} +

    +

    + {% if report.past_due %}{% trans "Past due" %}{% endif %} {% if report.is_very_late %} - {% heroicon_outline 'exclamation-circle' stroke_width=2 size=22 class="inline me-1 stroke-red-500" aria_hidden=true %} + {% heroicon_outline 'exclamation-circle' stroke_width=2 size=22 class="inline me-1 stroke-error" aria_hidden=true %} {% endif %} -

    - {% if report.can_submit %} -
    +
    + {% if report.can_submit %} - {% if report.draft %}{% trans "Continue Editing" %}{% else %}{% trans "Add Report" %}{% endif %} + {% if report.draft %}{% trans "Continue Editing" %}{% else %}{% trans "Create report" %}{% endif %} - {% if request.user.is_apply_staff and report.can_submit %} + {% if request.user.is_apply_staff %} {% endif %} -
    - {% endif %} + {% endif %} + {% if request.user.is_apply_staff %} + + {% heroicon_micro "trash" aria_hidden=true %} + + {% endif %} +
  • diff --git a/hypha/apply/projects/reports/templates/reports/includes/reports.html b/hypha/apply/projects/reports/templates/reports/includes/reports.html index e859a85c77..35b1a7ddbc 100644 --- a/hypha/apply/projects/reports/templates/reports/includes/reports.html +++ b/hypha/apply/projects/reports/templates/reports/includes/reports.html @@ -28,6 +28,15 @@

    {% trans "Update" %} {% endif %} + · + + {% trans "Add" %} + {% endif %} @@ -38,11 +47,9 @@

    {% for report in object.report_config.past_due_reports %} {% include "reports/includes/report_line.html" with report=report %} {% endfor %} - {% with next_report=object.report_config.current_due_report %} - {% if next_report %} - {% include "reports/includes/report_line.html" with report=next_report current=True %} - {% endif %} - {% endwith %} + {% for report in object.report_config.future_due_reports %} + {% include "reports/includes/report_line.html" with report=report %} + {% endfor %} {% endif %} diff --git a/hypha/apply/projects/reports/templates/reports/modals/add_report.html b/hypha/apply/projects/reports/templates/reports/modals/add_report.html new file mode 100644 index 0000000000..216d4bcb46 --- /dev/null +++ b/hypha/apply/projects/reports/templates/reports/modals/add_report.html @@ -0,0 +1,22 @@ +{% load i18n %} +{% trans "Add report date" %} + +
    +
    + {% csrf_token %} + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + +
    + +
    +
    +
    diff --git a/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html new file mode 100644 index 0000000000..ee7175e8e4 --- /dev/null +++ b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html @@ -0,0 +1,6 @@ +{% load i18n %} + +

    + {% trans "Are you sure you want to delete this report? This action cannot be undone." %} +

    +
    diff --git a/hypha/apply/projects/reports/templates/reports/modals/edit_report_due_date.html b/hypha/apply/projects/reports/templates/reports/modals/edit_report_due_date.html new file mode 100644 index 0000000000..709b53e9cc --- /dev/null +++ b/hypha/apply/projects/reports/templates/reports/modals/edit_report_due_date.html @@ -0,0 +1,22 @@ +{% load i18n %} +{% trans "Edit report due date" %} + +
    +
    + {% csrf_token %} + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + +
    + +
    +
    +
    diff --git a/hypha/apply/projects/reports/tests/test_models.py b/hypha/apply/projects/reports/tests/test_models.py index 61e54cf37e..8afc585c0f 100644 --- a/hypha/apply/projects/reports/tests/test_models.py +++ b/hypha/apply/projects/reports/tests/test_models.py @@ -74,7 +74,7 @@ def test_current_due_report_gets_active_report(self): def test_no_report_creates_report(self): config = ReportConfigFactory(disable_reporting=False) - report = config.current_due_report() + report = config.ensure_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) @@ -91,14 +91,14 @@ def test_no_report_creates_report_not_in_past(self): config = ReportConfigFactory( schedule_start=self.today - relativedelta(months=3), disable_reporting=False ) - report = config.current_due_report() + report = config.ensure_due_report() assert Report.objects.count() == 1 assert report.end_date == self.today def test_no_report_creates_report_if_current_skipped(self): config = ReportConfigFactory(disable_reporting=False) skipped_report = ReportFactory(end_date=self.today + relativedelta(days=3)) - report = config.current_due_report() + report = config.ensure_due_report() assert Report.objects.count() == 2 assert skipped_report != report @@ -106,7 +106,7 @@ def test_no_report_schedule_in_future_creates_report(self): config = ReportConfigFactory( schedule_start=self.today + relativedelta(days=2), disable_reporting=False ) - report = config.current_due_report() + report = config.ensure_due_report() assert Report.objects.count() == 1 assert report.end_date == self.today + relativedelta(days=2) @@ -124,13 +124,13 @@ def test_past_due_report_creates_report(self): # separate => 31th - 1 day = 30th + 1 month = 30th (correct) next_due = self.today - relativedelta(days=1) + relativedelta(months=1) - report = config.current_due_report() + report = config.ensure_due_report() assert Report.objects.count() == 2 assert report.end_date == next_due def test_today_schedule_gets_report_today(self): config = ReportConfigFactory(disable_reporting=False, schedule_start=self.today) - assert config.current_due_report().end_date == self.today + assert config.ensure_due_report().end_date == self.today def test_past_due_report_future_schedule_creates_report(self): config = ReportConfigFactory( @@ -140,7 +140,7 @@ def test_past_due_report_future_schedule_creates_report(self): project=config.project, end_date=self.today - relativedelta(days=1) ) - report = config.current_due_report() + report = config.ensure_due_report() assert Report.objects.count() == 2 assert report.end_date == self.today + relativedelta(days=3) @@ -189,6 +189,175 @@ def test_past_due_no_skipped(self): self.assertQuerySetEqual(config.past_due_reports(), [], transform=lambda x: x) +class TestCurrentDueReport(TestCase): + """Tests for ReportConfig.current_due_report() — a pure read, never creates rows.""" + + @property + def today(self): + return timezone.now().date() + + def test_returns_none_when_reporting_disabled(self): + config = ReportConfigFactory(disable_reporting=True) + assert config.current_due_report() is None + + def test_returns_none_without_proposed_start(self): + config = ReportConfigFactory( + disable_reporting=False, project__proposed_start=None + ) + assert config.current_due_report() is None + + def test_returns_none_when_no_pending_reports_exist(self): + config = ReportConfigFactory(disable_reporting=False) + assert config.current_due_report() is None + + def test_returns_pending_future_report(self): + config = ReportConfigFactory(disable_reporting=False) + report = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=1) + ) + assert config.current_due_report() == report + + def test_returns_earliest_when_multiple_pending(self): + config = ReportConfigFactory(disable_reporting=False) + ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=7) + ) + earlier = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=1) + ) + assert config.current_due_report() == earlier + + def test_does_not_create_reports(self): + config = ReportConfigFactory(disable_reporting=False) + config.current_due_report() + assert Report.objects.count() == 0 + + def test_excludes_submitted_reports(self): + config = ReportConfigFactory(disable_reporting=False) + ReportFactory( + project=config.project, + is_submitted=True, + end_date=self.today + relativedelta(days=1), + ) + assert config.current_due_report() is None + + def test_excludes_skipped_reports(self): + config = ReportConfigFactory(disable_reporting=False) + ReportFactory( + project=config.project, + skipped=True, + end_date=self.today + relativedelta(days=1), + ) + assert config.current_due_report() is None + + +class TestFutureDueReports(TestCase): + """Tests for ReportConfig.future_due_reports() — returns all pending future reports.""" + + @property + def today(self): + return timezone.now().date() + + def test_returns_pending_future_report(self): + config = ReportConfigFactory() + report = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=1) + ) + self.assertQuerySetEqual( + config.future_due_reports(), [report], transform=lambda x: x + ) + + def test_includes_report_due_today(self): + config = ReportConfigFactory() + report = ReportFactory(project=config.project, end_date=self.today) + self.assertQuerySetEqual( + config.future_due_reports(), [report], transform=lambda x: x + ) + + def test_excludes_past_due_reports(self): + config = ReportConfigFactory() + ReportFactory( + project=config.project, end_date=self.today - relativedelta(days=1) + ) + self.assertQuerySetEqual(config.future_due_reports(), [], transform=lambda x: x) + + def test_excludes_submitted_reports(self): + config = ReportConfigFactory() + ReportFactory( + project=config.project, + is_submitted=True, + end_date=self.today + relativedelta(days=1), + ) + self.assertQuerySetEqual(config.future_due_reports(), [], transform=lambda x: x) + + def test_excludes_skipped_reports(self): + config = ReportConfigFactory() + ReportFactory( + project=config.project, + skipped=True, + end_date=self.today + relativedelta(days=1), + ) + self.assertQuerySetEqual(config.future_due_reports(), [], transform=lambda x: x) + + def test_ordered_by_end_date_ascending(self): + config = ReportConfigFactory() + later = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=7) + ) + earlier = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=1) + ) + self.assertQuerySetEqual( + config.future_due_reports(), [earlier, later], transform=lambda x: x + ) + + def test_returns_empty_queryset_when_none(self): + config = ReportConfigFactory() + self.assertQuerySetEqual(config.future_due_reports(), [], transform=lambda x: x) + + def test_scoped_to_project(self): + config = ReportConfigFactory() + ReportFactory(end_date=self.today + relativedelta(days=1)) # different project + self.assertQuerySetEqual(config.future_due_reports(), [], transform=lambda x: x) + + +class TestEnsureDueReport(TestCase): + """Tests for ReportConfig.ensure_due_report() — finds or creates the next report row.""" + + @property + def today(self): + return timezone.now().date() + + def test_returns_none_when_reporting_disabled(self): + config = ReportConfigFactory(disable_reporting=True) + assert config.ensure_due_report() is None + + def test_returns_none_without_proposed_start(self): + config = ReportConfigFactory( + disable_reporting=False, project__proposed_start=None + ) + assert config.ensure_due_report() is None + + def test_returns_existing_pending_report(self): + config = ReportConfigFactory(disable_reporting=False) + existing = ReportFactory( + project=config.project, end_date=self.today + relativedelta(days=1) + ) + assert config.ensure_due_report() == existing + assert Report.objects.filter(project=config.project).count() == 1 + + def test_is_idempotent(self): + config = ReportConfigFactory(disable_reporting=False) + config.ensure_due_report() + config.ensure_due_report() + assert Report.objects.filter(project=config.project).count() == 1 + + def test_returns_none_for_one_time_config_after_submission(self): + config = ReportConfigFactory(disable_reporting=False, does_not_repeat=True) + ReportFactory(project=config.project, is_submitted=True) + assert config.ensure_due_report() is None + + class TestReport(TestCase): """Tests for the Report model class.""" diff --git a/hypha/apply/projects/reports/tests/test_views.py b/hypha/apply/projects/reports/tests/test_views.py index 4cd6a70bdd..daa517a2ba 100644 --- a/hypha/apply/projects/reports/tests/test_views.py +++ b/hypha/apply/projects/reports/tests/test_views.py @@ -19,6 +19,7 @@ ) from hypha.apply.utils.testing.tests import BaseViewTestCase +from ..models import Report from .factories import ( ReportConfigFactory, ReportFactory, @@ -696,3 +697,125 @@ def test_applicant_cant_access(self): ) response = self.client.get(url) assert response.status_code == 403 + + +class TestReportDateAddView(BaseViewTestCase): + base_view_name = "report_add" + url_name = "funds:projects:{}" + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {"pk": instance.submission.id} + + def test_staff_can_get_form(self): + project = ProjectFactory(status=INVOICING_AND_REPORTING) + ReportConfigFactory(project=project) + response = self.get_page(project) + assert response.status_code == 200 + + def test_staff_can_add_report(self): + project = ProjectFactory(status=INVOICING_AND_REPORTING) + ReportConfigFactory(project=project) + new_date = timezone.now().date() + relativedelta(months=1) + response = self.post_page(project, {"end_date": new_date.isoformat()}) + assert response.status_code == 200 + assert project.reports.filter(end_date=new_date).exists() + + def test_duplicate_date_rejected(self): + project = ProjectFactory(status=INVOICING_AND_REPORTING) + ReportConfigFactory(project=project) + existing_date = timezone.now().date() + ReportFactory(project=project, end_date=existing_date) + response = self.post_page(project, {"end_date": existing_date.isoformat()}) + assert response.status_code == 200 + assert project.reports.filter(end_date=existing_date).count() == 1 + + def test_applicant_cannot_access(self): + project = ProjectFactory(status=INVOICING_AND_REPORTING) + self.client.force_login(ApplicantFactory()) + url = reverse("funds:projects:report_add", kwargs={"pk": project.submission.id}) + response = self.client.get(url) + assert response.status_code == 403 + + +class TestReportEditDueDateView(BaseViewTestCase): + base_view_name = "edit_due_date" + url_name = "funds:projects:reports:{}" + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {"pk": instance.pk} + + def test_staff_can_get_form(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + response = self.get_page(report) + assert response.status_code == 200 + + def test_staff_can_edit_due_date(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + new_date = timezone.now().date() + relativedelta(days=7) + response = self.post_page(report, {"end_date": new_date.isoformat()}) + assert response.status_code == 200 + report.refresh_from_db() + assert report.end_date == new_date + + def test_duplicate_date_rejected(self): + today = timezone.now().date() + report_a = ReportFactory( + project__status=INVOICING_AND_REPORTING, end_date=today + ) + report_b = ReportFactory( + project=report_a.project, end_date=today + relativedelta(days=7) + ) + response = self.post_page(report_a, {"end_date": report_b.end_date.isoformat()}) + assert response.status_code == 200 + report_a.refresh_from_db() + assert report_a.end_date == today + + def test_submitted_report_cannot_be_edited(self): + today = timezone.now().date() + report = ReportFactory( + project__status=INVOICING_AND_REPORTING, + is_submitted=True, + end_date=today, + ) + response = self.post_page( + report, {"end_date": (today + relativedelta(days=7)).isoformat()} + ) + assert response.status_code == 404 + report.refresh_from_db() + assert report.end_date == today + + def test_applicant_cannot_access(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + self.client.force_login(ApplicantFactory()) + url = reverse("funds:projects:reports:edit_due_date", kwargs={"pk": report.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + +class TestReportDeleteView(BaseViewTestCase): + base_view_name = "delete" + url_name = "funds:projects:reports:{}" + user_factory = StaffFactory + + def get_kwargs(self, instance): + return {"pk": instance.pk} + + def test_staff_can_get_confirm(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + response = self.get_page(report) + assert response.status_code == 200 + + def test_staff_can_delete_report(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + report_pk = report.pk + self.post_page(report) + assert not Report.objects.filter(pk=report_pk).exists() + + def test_applicant_cannot_access(self): + report = ReportFactory(project__status=INVOICING_AND_REPORTING) + self.client.force_login(ApplicantFactory()) + url = reverse("funds:projects:reports:delete", kwargs={"pk": report.pk}) + response = self.client.get(url) + assert response.status_code == 403 diff --git a/hypha/apply/projects/reports/urls.py b/hypha/apply/projects/reports/urls.py index 51bb3232bd..86b13526ce 100644 --- a/hypha/apply/projects/reports/urls.py +++ b/hypha/apply/projects/reports/urls.py @@ -1,7 +1,9 @@ from django.urls import include, path from .views import ( + ReportDeleteView, ReportDetailView, + ReportEditDueDateView, ReportingView, ReportListView, ReportPrivateMedia, @@ -21,6 +23,16 @@ path("", ReportDetailView.as_view(), name="detail"), path("skip/", ReportSkipView.as_view(), name="skip"), path("edit/", ReportUpdateView.as_view(), name="edit"), + path( + "edit-due-date/", + ReportEditDueDateView.as_view(), + name="edit_due_date", + ), + path( + "delete/", + ReportDeleteView.as_view(), + name="delete", + ), path( "documents//", ReportPrivateMedia.as_view(), diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 913d131433..97238cc3db 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -14,8 +14,9 @@ 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 HttpResponseRedirect +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views import View @@ -34,7 +35,12 @@ from hypha.apply.utils.storage import PrivateMediaView from .filters import ReportingFilter, ReportListFilter -from .forms import ReportEditForm, ReportFrequencyForm +from .forms import ( + ReportAddDateForm, + ReportEditDueDateForm, + ReportEditForm, + ReportFrequencyForm, +) from .models import Report, ReportConfig, ReportPrivateFiles from .tables import ReportingTable, ReportListTable @@ -514,6 +520,137 @@ def post(self, *args, **kwargs): ) +@method_decorator(staff_required, name="dispatch") +class ReportDateAddView(View): + """ + View for manually adding an ad-hoc report date for a project. + + Allows staff to create a report for a specific past date outside the regular schedule. + """ + + form_class = ReportAddDateForm + template_name = "reports/modals/add_report.html" + permission_denied_message = _("You do not have permission to add reports.") + + def dispatch(self, request, *args, **kwargs): + self.project = get_object_or_404(Project, submission__id=kwargs.get("pk")) + if not has_object_permission( + "update_report_config", self.request.user, self.project + ): + raise PermissionDenied(self.permission_denied_message) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + if not request.htmx: + return redirect( + reverse( + "funds:submissions:project", + kwargs={"pk": self.project.submission.id}, + ) + ) + form = self.form_class(project=self.project) + return render( + request, + self.template_name, + {"form": form, "object": self.project}, + ) + + def post(self, request, *args, **kwargs): + form = self.form_class(request.POST, project=self.project) + if form.is_valid(): + Report.objects.create( + project=self.project, + end_date=form.cleaned_data["end_date"], + ) + return HttpResponseClientRefresh() + return render( + request, + self.template_name, + {"form": form, "object": self.project}, + ) + + +@method_decorator(staff_required, name="dispatch") +class ReportEditDueDateView(View): + form_class = ReportEditDueDateForm + template_name = "reports/modals/edit_report_due_date.html" + permission_denied_message = _( + "You do not have permission to edit report due dates." + ) + + def dispatch(self, request, *args, **kwargs): + self.report = get_object_or_404(Report, pk=kwargs.get("pk")) + if not has_object_permission( + "update_report_config", self.request.user, self.report.project + ): + raise PermissionDenied(self.permission_denied_message) + # A submitted report's period is fixed; its due date can't be changed. + if self.report.current: + raise Http404(_("A submitted report's due date cannot be edited.")) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + if not request.htmx: + return redirect( + reverse( + "funds:submissions:project", + kwargs={"pk": self.report.project.submission.id}, + ) + ) + form = self.form_class(instance=self.report) + return render( + request, self.template_name, {"form": form, "report": self.report} + ) + + def post(self, request, *args, **kwargs): + form = self.form_class(request.POST, instance=self.report) + if form.is_valid(): + form.save() + return HttpResponseClientRefresh() + return render( + request, self.template_name, {"form": form, "report": self.report} + ) + + +@method_decorator(staff_required, name="dispatch") +class ReportDeleteView(View): + template_name = "reports/modals/confirm_delete.html" + permission_denied_message = _("You do not have permission to delete reports.") + + def dispatch(self, request, *args, **kwargs): + self.report = get_object_or_404(Report, pk=kwargs.get("pk")) + if not has_object_permission( + "update_report_config", self.request.user, self.report.project + ): + raise PermissionDenied(self.permission_denied_message) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + if not request.htmx: + return redirect( + reverse( + "funds:submissions:project", + kwargs={"pk": self.report.project.submission.id}, + ) + ) + return render(request, self.template_name, {"report": self.report}) + + def post(self, request, *args, **kwargs): + project_url = reverse( + "funds:submissions:project", + kwargs={"pk": self.report.project.submission.id}, + ) + messenger( + MESSAGES.DELETE_REPORT, + request=request, + user=request.user, + source=self.report.project, + related=self.report, + ) + self.report.delete() + return HttpResponseRedirect(project_url) + + @method_decorator(staff_or_finance_required, name="dispatch") class ReportListView(SingleTableMixin, FilterView): """ diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index 01c2b00e9d..c5cdc419cd 100644 --- a/hypha/apply/projects/urls.py +++ b/hypha/apply/projects/urls.py @@ -3,7 +3,7 @@ from hypha.apply.projects.views.project import ProjectSOWEditView -from .reports.views import ReportFrequencyUpdate +from .reports.views import ReportDateAddView, ReportFrequencyUpdate from .views import ( ApproveContractView, BatchUpdateInvoiceStatusView, @@ -207,6 +207,11 @@ ReportFrequencyUpdate.as_view(), name="report_frequency_update", ), + path( + "reports/add/", + ReportDateAddView.as_view(), + name="report_add", + ), path("invoice/", CreateInvoiceView.as_view(), name="invoice"), path( "partial/invoice-status/", diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index da213273af..cdc9caa6d8 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -1649,6 +1649,8 @@ def get_context_data(self, **kwargs): context["contracting_documents_configured"] = ( True if ContractDocumentCategory.objects.count() else False ) + if hasattr(self.object, "report_config"): + self.object.report_config.ensure_due_report() return context