Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hypha/apply/activity/adapters/activity_feed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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}

Expand Down
1 change: 1 addition & 0 deletions hypha/apply/activity/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions hypha/apply/activity/adapters/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: _(
Expand Down
85 changes: 85 additions & 0 deletions hypha/apply/activity/migrations/0094_alter_event_type.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
1 change: 1 addition & 0 deletions hypha/apply/activity/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
36 changes: 36 additions & 0 deletions hypha/apply/projects/reports/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
68 changes: 59 additions & 9 deletions hypha/apply/projects/reports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,54 @@
{% load i18n heroicons %}
<li class="list-row @container">
<p class="text-sm list-col-grow">

{% 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" %}
<p class="text-sm{% if report.can_submit %} font-semibold{% endif %}">
<relative-time datetime="{{ report.end_date|date:'c' }}">{{ report.end_date }}</relative-time>
{% if request.user.is_apply_staff %}
<a
class="text-sm font-normal link ms-1"
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" %}</a>
{% endif %}
<b class="font-semibold">{{ report.start_date }}</b> {% trans "to" %} <b class="font-semibold">{{ report.end_date }}</b>
</p>
<p class="text-sm">
{% if report.past_due %}<span class="font-semibold text-error">{% trans "Past due" %}</span>{% 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 %}

</p>

{% if report.can_submit %}
<div class="list-col-wrap @sm:row-start-1 card-actions">
<div class="card-actions">
{% if report.can_submit %}
<a
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 %}
</a>
{% if request.user.is_apply_staff and report.can_submit %}
{% if request.user.is_apply_staff %}
<button
hx-post="{% url 'apply:projects:reports:skip' pk=report.id %}"
hx-confirm="{% blocktrans with start_date=report.start_date end_date=report.end_date %} You're skipping the report for {{ start_date }} - {{ end_date }}. This will result in a gap in reporting for the project. You can undo this at any time. {% endblocktrans %}"
value="{% trans 'Skip' %}"
class="uppercase btn btn-sm"
class="btn btn-sm"
aria-label="{% trans "Skip this report date" %}"
>
{% trans 'Skip' %}
</button>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if request.user.is_apply_staff %}
<a
class="hover:text-white btn btn-sm btn-ghost btn-error"
href="{% url 'apply:projects:reports:delete' pk=report.pk %}"
hx-get="{% url 'apply:projects:reports:delete' pk=report.pk %}"
hx-target="#htmx-modal"
aria-label="{% trans "Delete report requirement" %}"
>
{% heroicon_micro "trash" aria_hidden=true %}
</a>
{% endif %}
</div>
</li>
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ <h2 class="card-title">
{% trans "Update" %}
{% endif %}
</a>
&middot;
<a
class="link"
href="{% url 'apply:projects:report_add' pk=object.submission.id %}"
hx-get="{% url 'apply:projects:report_add' pk=object.submission.id %}"
hx-target="#htmx-modal"
>
{% trans "Add" %}
</a>
{% endif %}
</div>
</header>
Expand All @@ -38,11 +47,9 @@ <h2 class="card-title">
{% 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 %}
</ul>
{% endif %}

Expand Down
Loading