From 70632b3dd733eba2b247920a9cb00c92b68561bc Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Fri, 19 Jun 2026 23:56:55 +0200 Subject: [PATCH 01/18] Make it possible to manually add report dates. --- hypha/apply/projects/reports/forms.py | 20 +++++++++ .../reports/includes/report_line.html | 4 +- .../templates/reports/includes/reports.html | 9 ++++ .../templates/reports/modals/add_report.html | 23 ++++++++++ hypha/apply/projects/reports/views.py | 45 ++++++++++++++++++- hypha/apply/projects/urls.py | 7 ++- 6 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 hypha/apply/projects/reports/templates/reports/modals/add_report.html diff --git a/hypha/apply/projects/reports/forms.py b/hypha/apply/projects/reports/forms.py index 8dd8766029..2fd303a402 100644 --- a/hypha/apply/projects/reports/forms.py +++ b/hypha/apply/projects/reports/forms.py @@ -96,6 +96,26 @@ 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"] + today = timezone.now().date() + if end_date >= today: + raise forms.ValidationError(_("Report date must be in the past.")) + 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 ReportFrequencyForm(forms.ModelForm): start = forms.DateField(label=_("Report on:"), required=False) 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..d7bbadb81a 100644 --- a/hypha/apply/projects/reports/templates/reports/includes/report_line.html +++ b/hypha/apply/projects/reports/templates/reports/includes/report_line.html @@ -20,14 +20,14 @@ class="btn btn-primary btn-sm" href="{% url "apply:projects:reports:edit" pk=report.pk %}" > - {% 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 %} diff --git a/hypha/apply/projects/reports/templates/reports/includes/reports.html b/hypha/apply/projects/reports/templates/reports/includes/reports.html index e859a85c77..6197248dc5 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 %} 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..878ff1db2d --- /dev/null +++ b/hypha/apply/projects/reports/templates/reports/modals/add_report.html @@ -0,0 +1,23 @@ +{% load i18n %} +{% trans "Add report" %} + +
+
+ {% csrf_token %} + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + +
+ +
+
+
diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 913d131433..e3b6c34ded 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -34,7 +34,7 @@ from hypha.apply.utils.storage import PrivateMediaView from .filters import ReportingFilter, ReportListFilter -from .forms import ReportEditForm, ReportFrequencyForm +from .forms import ReportAddDateForm, ReportEditForm, ReportFrequencyForm from .models import Report, ReportConfig, ReportPrivateFiles from .tables import ReportingTable, ReportListTable @@ -514,6 +514,49 @@ def post(self, *args, **kwargs): ) +@method_decorator(staff_required, name="dispatch") +class ReportAddView(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): + 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_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..8d79220857 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 ReportAddView, ReportFrequencyUpdate from .views import ( ApproveContractView, BatchUpdateInvoiceStatusView, @@ -207,6 +207,11 @@ ReportFrequencyUpdate.as_view(), name="report_frequency_update", ), + path( + "reports/add/", + ReportAddView.as_view(), + name="report_add", + ), path("invoice/", CreateInvoiceView.as_view(), name="invoice"), path( "partial/invoice-status/", From 251558ec4c09ba8fb65b766f853e9cb703fe1921 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 00:53:07 +0200 Subject: [PATCH 02/18] Allow report due time to be edited by staff. Simplify report lines formating. --- hypha/apply/projects/reports/forms.py | 19 +++++++++++ .../reports/includes/report_line.html | 24 ++++++++------ .../reports/modals/edit_report_due_date.html | 23 +++++++++++++ hypha/apply/projects/reports/urls.py | 6 ++++ hypha/apply/projects/reports/views.py | 33 ++++++++++++++++++- 5 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 hypha/apply/projects/reports/templates/reports/modals/edit_report_due_date.html diff --git a/hypha/apply/projects/reports/forms.py b/hypha/apply/projects/reports/forms.py index 2fd303a402..d34993ccf3 100644 --- a/hypha/apply/projects/reports/forms.py +++ b/hypha/apply/projects/reports/forms.py @@ -116,6 +116,25 @@ def clean_end_date(self): 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 + 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/templates/reports/includes/report_line.html b/hypha/apply/projects/reports/templates/reports/includes/report_line.html index d7bbadb81a..1edea2b1bc 100644 --- a/hypha/apply/projects/reports/templates/reports/includes/report_line.html +++ b/hypha/apply/projects/reports/templates/reports/includes/report_line.html @@ -1,21 +1,25 @@ {% 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 %} -
    +
    {% trans "Edit report due date" %} + +
    +
    + {% csrf_token %} + {% for field in form %} + {% include "forms/includes/field.html" %} + {% endfor %} + +
    + +
    +
    +
    diff --git a/hypha/apply/projects/reports/urls.py b/hypha/apply/projects/reports/urls.py index 51bb3232bd..733dc492c7 100644 --- a/hypha/apply/projects/reports/urls.py +++ b/hypha/apply/projects/reports/urls.py @@ -2,6 +2,7 @@ from .views import ( ReportDetailView, + ReportEditDueDateView, ReportingView, ReportListView, ReportPrivateMedia, @@ -21,6 +22,11 @@ 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( "documents//", ReportPrivateMedia.as_view(), diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index e3b6c34ded..52d31dffc4 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -34,7 +34,12 @@ from hypha.apply.utils.storage import PrivateMediaView from .filters import ReportingFilter, ReportListFilter -from .forms import ReportAddDateForm, ReportEditForm, ReportFrequencyForm +from .forms import ( + ReportAddDateForm, + ReportEditDueDateForm, + ReportEditForm, + ReportFrequencyForm, +) from .models import Report, ReportConfig, ReportPrivateFiles from .tables import ReportingTable, ReportListTable @@ -557,6 +562,32 @@ def post(self, request, *args, **kwargs): ) +@method_decorator(staff_required, name="dispatch") +class ReportEditDueDateView(SingleObjectMixin, View): + """ + View for editing the due date of a report. + + Allows staff to change the end_date of an existing report. + """ + + model = Report + form_class = ReportEditDueDateForm + template_name = "reports/modals/edit_report_due_date.html" + + def get(self, request, *args, **kwargs): + report = self.get_object() + form = self.form_class(instance=report) + return render(request, self.template_name, {"form": form, "report": report}) + + def post(self, request, *args, **kwargs): + report = self.get_object() + form = self.form_class(request.POST, instance=report) + if form.is_valid(): + form.save() + return HttpResponseClientRefresh() + return render(request, self.template_name, {"form": form, "report": report}) + + @method_decorator(staff_or_finance_required, name="dispatch") class ReportListView(SingleTableMixin, FilterView): """ From 203a919823c21acc2017e10b78d6c8b30c889196 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 00:54:59 +0200 Subject: [PATCH 03/18] Allow future report dates when adding/edit. --- hypha/apply/projects/reports/forms.py | 3 --- hypha/apply/projects/reports/models.py | 24 +++++++++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/hypha/apply/projects/reports/forms.py b/hypha/apply/projects/reports/forms.py index d34993ccf3..cae12f052c 100644 --- a/hypha/apply/projects/reports/forms.py +++ b/hypha/apply/projects/reports/forms.py @@ -108,9 +108,6 @@ def __init__(self, *args, project=None, **kwargs): def clean_end_date(self): end_date = self.cleaned_data["end_date"] - today = timezone.now().date() - if end_date >= today: - raise forms.ValidationError(_("Report date must be in the past.")) 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 diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index 9ac4c372ef..ad5f448ba1 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -377,13 +377,23 @@ 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}, - ) + try: + 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}, + ) + except Report.MultipleObjectsReturned: + # Multiple unsubmitted future reports exist (e.g. a due date was manually + # edited into the future). Find or create one for the calculated due date. + report, _ = self.project.reports.get_or_create( + project=self.project, + current__isnull=True, + skipped=False, + end_date=next_due_date, + ) return report def current_report(self): From 14e8ef589b9589b596c36790fb28dc0999e9a1e1 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 11:18:40 +0200 Subject: [PATCH 04/18] Add permissions to new views. --- hypha/apply/projects/reports/views.py | 31 ++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 52d31dffc4..5aba2d2946 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -564,28 +564,35 @@ def post(self, request, *args, **kwargs): @method_decorator(staff_required, name="dispatch") class ReportEditDueDateView(SingleObjectMixin, View): - """ - View for editing the due date of a report. - - Allows staff to change the end_date of an existing report. - """ - model = Report 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) + return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): - report = self.get_object() - form = self.form_class(instance=report) - return render(request, self.template_name, {"form": form, "report": report}) + form = self.form_class(instance=self.report) + return render( + request, self.template_name, {"form": form, "report": self.report} + ) def post(self, request, *args, **kwargs): - report = self.get_object() - form = self.form_class(request.POST, instance=report) + 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": report}) + return render( + request, self.template_name, {"form": form, "report": self.report} + ) @method_decorator(staff_or_finance_required, name="dispatch") From 7625f117950990809a2010317840e130c832db28 Mon Sep 17 00:00:00 2001 From: Frank Duncan Date: Sat, 20 Jun 2026 11:44:03 +0200 Subject: [PATCH 05/18] Improve way current_due_report creates reports. --- hypha/apply/projects/reports/models.py | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index ad5f448ba1..7fd6667f7d 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -377,23 +377,23 @@ def current_due_report(self): today, ) - try: - 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}, - ) - except Report.MultipleObjectsReturned: - # Multiple unsubmitted future reports exist (e.g. a due date was manually - # edited into the future). Find or create one for the calculated due date. - report, _ = self.project.reports.get_or_create( + # If there is a report due, then we do not update the date, as the date can be updated + # via updating the specific report requirement. If there is not one, and we should create + # it, then we do that. + due_reports = self.project.reports.filter( + project=self.project, + 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, - current__isnull=True, - skipped=False, end_date=next_due_date, ) + return report def current_report(self): From a92cc9e039d107c55694cc2e4d63da960bad5a2e Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 11:49:50 +0200 Subject: [PATCH 06/18] Rename ReportAddView to ReportDateAddView. --- hypha/apply/projects/reports/views.py | 2 +- hypha/apply/projects/urls.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 5aba2d2946..e8a44d9c3a 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -520,7 +520,7 @@ def post(self, *args, **kwargs): @method_decorator(staff_required, name="dispatch") -class ReportAddView(View): +class ReportDateAddView(View): """ View for manually adding an ad-hoc report date for a project. diff --git a/hypha/apply/projects/urls.py b/hypha/apply/projects/urls.py index 8d79220857..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 ReportAddView, ReportFrequencyUpdate +from .reports.views import ReportDateAddView, ReportFrequencyUpdate from .views import ( ApproveContractView, BatchUpdateInvoiceStatusView, @@ -209,7 +209,7 @@ ), path( "reports/add/", - ReportAddView.as_view(), + ReportDateAddView.as_view(), name="report_add", ), path("invoice/", CreateInvoiceView.as_view(), name="invoice"), From 79a5325eefa5f71d4ebfe14e05257a41691c1228 Mon Sep 17 00:00:00 2001 From: Frank Duncan Date: Sat, 20 Jun 2026 12:18:11 +0200 Subject: [PATCH 07/18] Add delete report feature. --- .../reports/includes/report_line.html | 23 +++++++++++---- .../reports/modals/confirm_delete.html | 28 +++++++++++++++++++ hypha/apply/projects/reports/urls.py | 6 ++++ hypha/apply/projects/reports/views.py | 21 ++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html 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 1edea2b1bc..1f5f92cb52 100644 --- a/hypha/apply/projects/reports/templates/reports/includes/report_line.html +++ b/hypha/apply/projects/reports/templates/reports/includes/report_line.html @@ -8,6 +8,7 @@ href="{% url 'apply:projects:reports:edit_due_date' pk=report.pk %}" hx-get="{% url 'apply:projects:reports:edit_due_date' pk=report.pk %}" hx-target="#htmx-modal" + aria-label="{% trans "Edit this report date" %}" >{% trans "edit" %}
    {% endif %}

    @@ -18,24 +19,36 @@ {% endif %}

    - {% if report.can_submit %} -
    +
    + {% if report.can_submit %} {% 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/modals/confirm_delete.html b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html new file mode 100644 index 0000000000..4c6d13b051 --- /dev/null +++ b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html @@ -0,0 +1,28 @@ +{% load i18n heroicons %} +{% trans "Delete report requirement" %} + +
    +
    + {% csrf_token %} +
    + {% heroicon_outline "exclamation-triangle" class="mt-0.5 text-error shrink-0" stroke_width=1.5 aria_hidden=true %} +

    + {% if report.submitted or report.draft %} + {% trans "Are you sure you want to delete this report? The entire report will be permanently removed. This action cannot be undone." %} + {% else %} + {% trans "Are you sure you want to delete this report requirement?" %} + {% endif %} +

    +
    +
    + +
    +
    +
    diff --git a/hypha/apply/projects/reports/urls.py b/hypha/apply/projects/reports/urls.py index 733dc492c7..86b13526ce 100644 --- a/hypha/apply/projects/reports/urls.py +++ b/hypha/apply/projects/reports/urls.py @@ -1,6 +1,7 @@ from django.urls import include, path from .views import ( + ReportDeleteView, ReportDetailView, ReportEditDueDateView, ReportingView, @@ -27,6 +28,11 @@ 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 e8a44d9c3a..70bcfd2aed 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -595,6 +595,27 @@ def post(self, request, *args, **kwargs): ) +@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): + return render(request, self.template_name, {"report": self.report}) + + def post(self, request, *args, **kwargs): + self.report.delete() + return HttpResponseClientRefresh() + + @method_decorator(staff_or_finance_required, name="dispatch") class ReportListView(SingleTableMixin, FilterView): """ From 785b512363bc08384ae98754670db75dfd6d4899 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 12:27:01 +0200 Subject: [PATCH 08/18] Make the report delete confirm behave the same as other delete confirm. --- .../reports/modals/confirm_delete.html | 34 ++++--------------- hypha/apply/projects/reports/views.py | 7 +++- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html index 4c6d13b051..ee7175e8e4 100644 --- a/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html +++ b/hypha/apply/projects/reports/templates/reports/modals/confirm_delete.html @@ -1,28 +1,6 @@ -{% load i18n heroicons %} -{% trans "Delete report requirement" %} - -
    -
    - {% csrf_token %} -
    - {% heroicon_outline "exclamation-triangle" class="mt-0.5 text-error shrink-0" stroke_width=1.5 aria_hidden=true %} -

    - {% if report.submitted or report.draft %} - {% trans "Are you sure you want to delete this report? The entire report will be permanently removed. This action cannot be undone." %} - {% else %} - {% trans "Are you sure you want to delete this report requirement?" %} - {% endif %} -

    -
    -
    - -
    -
    -
    +{% load i18n %} + +

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

    +
    diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 70bcfd2aed..435e74e3d1 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -16,6 +16,7 @@ from django.core.exceptions import PermissionDenied from django.http import 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 @@ -612,8 +613,12 @@ def get(self, request, *args, **kwargs): 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}, + ) self.report.delete() - return HttpResponseClientRefresh() + return HttpResponseRedirect(project_url) @method_decorator(staff_or_finance_required, name="dispatch") From 5c6c5a0e6711351d191a7a6353a14f8e842b430a Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 12:35:57 +0200 Subject: [PATCH 09/18] List all future report dates. --- hypha/apply/projects/reports/models.py | 8 ++++++++ .../reports/templates/reports/includes/report_line.html | 2 +- .../reports/templates/reports/includes/reports.html | 8 +++----- .../reports/templates/reports/modals/add_report.html | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index 7fd6667f7d..206a6a4640 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -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: 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 1f5f92cb52..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,6 +1,6 @@ {% load i18n heroicons %}
  • -

    +

    {{ report.end_date }} {% if request.user.is_apply_staff %} {% 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 index 878ff1db2d..49c2cb02c9 100644 --- a/hypha/apply/projects/reports/templates/reports/modals/add_report.html +++ b/hypha/apply/projects/reports/templates/reports/modals/add_report.html @@ -1,5 +1,5 @@ {% load i18n %} -{% trans "Add report" %} +{% trans "Add report date" %}

    From 76d83ba36822f1a886936b7a7cba39e9ebdba9aa Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 12:51:51 +0200 Subject: [PATCH 10/18] Fix possible race condition in current_due_report. --- hypha/apply/projects/reports/models.py | 31 +++++++++++++------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/hypha/apply/projects/reports/models.py b/hypha/apply/projects/reports/models.py index 206a6a4640..63f1737d7a 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 @@ -385,23 +385,24 @@ def current_due_report(self): today, ) - # If there is a report due, then we do not update the date, as the date can be updated - # via updating the specific report requirement. If there is not one, and we should create - # it, then we do that. - due_reports = self.project.reports.filter( - project=self.project, - current__isnull=True, - skipped=False, - end_date__gte=today, - ) + 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) - 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, + 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_report(self): From 809c0fa96fea2c7f5ceef170f58fbb4a88476c00 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 12:54:20 +0200 Subject: [PATCH 11/18] Remove unused SingleObjectMixin. --- hypha/apply/projects/reports/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 435e74e3d1..de8b17c30d 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -564,8 +564,7 @@ def post(self, request, *args, **kwargs): @method_decorator(staff_required, name="dispatch") -class ReportEditDueDateView(SingleObjectMixin, View): - model = Report +class ReportEditDueDateView(View): form_class = ReportEditDueDateForm template_name = "reports/modals/edit_report_due_date.html" permission_denied_message = _( From c283965871a96077838287ce60570ced9dc54a01 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 12:57:18 +0200 Subject: [PATCH 12/18] Minor cleanups. --- hypha/apply/projects/reports/forms.py | 2 +- .../projects/reports/templates/reports/modals/add_report.html | 1 - .../reports/templates/reports/modals/edit_report_due_date.html | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/hypha/apply/projects/reports/forms.py b/hypha/apply/projects/reports/forms.py index cae12f052c..dde40ccb1e 100644 --- a/hypha/apply/projects/reports/forms.py +++ b/hypha/apply/projects/reports/forms.py @@ -123,7 +123,7 @@ class Meta: def clean_end_date(self): end_date = self.cleaned_data["end_date"] if ( - self.instance + self.instance.pk and self.instance.project.reports.filter(end_date=end_date) .exclude(pk=self.instance.pk) .exists() diff --git a/hypha/apply/projects/reports/templates/reports/modals/add_report.html b/hypha/apply/projects/reports/templates/reports/modals/add_report.html index 49c2cb02c9..216d4bcb46 100644 --- a/hypha/apply/projects/reports/templates/reports/modals/add_report.html +++ b/hypha/apply/projects/reports/templates/reports/modals/add_report.html @@ -7,7 +7,6 @@ action="{% url 'apply:projects:report_add' pk=object.submission.id %}" hx-post="{% url 'apply:projects:report_add' pk=object.submission.id %}" hx-swap="innerHTML" - enctype="multipart/form-data" > {% csrf_token %} {% for field in form %} 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 index b64d6cf801..709b53e9cc 100644 --- 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 @@ -7,7 +7,6 @@ action="{% url 'apply:projects:reports:edit_due_date' pk=report.pk %}" hx-post="{% url 'apply:projects:reports:edit_due_date' pk=report.pk %}" hx-swap="innerHTML" - enctype="multipart/form-data" > {% csrf_token %} {% for field in form %} From 9e02b83b7b46c1739c89649bf023ad8aa03a6dc7 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 13:39:21 +0200 Subject: [PATCH 13/18] Sepererate current_due_report out to a current_due_report and a ensure_due_report functions. --- .../management/commands/notify_report_due.py | 2 +- hypha/apply/projects/reports/models.py | 33 ++++++++++++++++++- .../projects/reports/tests/test_models.py | 14 ++++---- hypha/apply/projects/views/project.py | 2 ++ 4 files changed, 42 insertions(+), 9 deletions(-) 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 63f1737d7a..c0ed2e90e2 100644 --- a/hypha/apply/projects/reports/models.py +++ b/hypha/apply/projects/reports/models.py @@ -344,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 @@ -405,6 +413,29 @@ def current_due_report(self): 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/tests/test_models.py b/hypha/apply/projects/reports/tests/test_models.py index 61e54cf37e..3784234a0e 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) 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 From 146559ddba2599734a1c8fea9b0135268d1809f3 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 14:05:12 +0200 Subject: [PATCH 14/18] Add test for new views and model functions. --- .../projects/reports/tests/test_models.py | 169 ++++++++++++++++++ .../projects/reports/tests/test_views.py | 109 +++++++++++ 2 files changed, 278 insertions(+) diff --git a/hypha/apply/projects/reports/tests/test_models.py b/hypha/apply/projects/reports/tests/test_models.py index 3784234a0e..8afc585c0f 100644 --- a/hypha/apply/projects/reports/tests/test_models.py +++ b/hypha/apply/projects/reports/tests/test_models.py @@ -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..9803021b0d 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,111 @@ 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_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 From 44a2d03dcc24f9e6c496ca63fd9e316832fa71cd Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 14:21:56 +0200 Subject: [PATCH 15/18] Only allow htmx aceess to ReportDateAddView, ReportEditDueDateView and ReportDeleteView. --- hypha/apply/projects/reports/views.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index de8b17c30d..6880feba42 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -541,6 +541,13 @@ def dispatch(self, request, *args, **kwargs): 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, @@ -580,6 +587,13 @@ def dispatch(self, request, *args, **kwargs): 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} @@ -609,6 +623,13 @@ def dispatch(self, request, *args, **kwargs): 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): From a001dc50f910e405e246a60c0f977e2d233d7470 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 15:25:18 +0200 Subject: [PATCH 16/18] Stop submitted reports from having date edited. --- hypha/apply/projects/reports/tests/test_views.py | 14 ++++++++++++++ hypha/apply/projects/reports/views.py | 5 ++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/hypha/apply/projects/reports/tests/test_views.py b/hypha/apply/projects/reports/tests/test_views.py index 9803021b0d..daa517a2ba 100644 --- a/hypha/apply/projects/reports/tests/test_views.py +++ b/hypha/apply/projects/reports/tests/test_views.py @@ -772,6 +772,20 @@ def test_duplicate_date_rejected(self): 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()) diff --git a/hypha/apply/projects/reports/views.py b/hypha/apply/projects/reports/views.py index 6880feba42..02e0a4121b 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -14,7 +14,7 @@ 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 @@ -584,6 +584,9 @@ def dispatch(self, request, *args, **kwargs): "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): From 51455c33a46a87499a5b9b706eb72def705ab2ec Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 15:33:50 +0200 Subject: [PATCH 17/18] Add notifications for DELETE_REPORT. --- hypha/apply/activity/adapters/activity_feed.py | 2 ++ hypha/apply/activity/adapters/base.py | 1 + hypha/apply/activity/adapters/slack.py | 3 +++ hypha/apply/activity/options.py | 1 + hypha/apply/projects/reports/views.py | 7 +++++++ 5 files changed, 14 insertions(+) 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/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/views.py b/hypha/apply/projects/reports/views.py index 02e0a4121b..97238cc3db 100644 --- a/hypha/apply/projects/reports/views.py +++ b/hypha/apply/projects/reports/views.py @@ -640,6 +640,13 @@ def post(self, request, *args, **kwargs): "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) From 91242d21c9133d9faf939b8ec67eab7106f6791b Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sat, 20 Jun 2026 15:57:51 +0200 Subject: [PATCH 18/18] Add missing migration. --- .../migrations/0094_alter_event_type.py | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 hypha/apply/activity/migrations/0094_alter_event_type.py 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", + ), + ), + ]