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 %}
-
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" %}
+
+
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" %}
+
+
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