diff --git a/docs/setup/administrators/setup-notifications.md b/docs/setup/administrators/setup-notifications.md index f45b7499e4..f3cb409d12 100644 --- a/docs/setup/administrators/setup-notifications.md +++ b/docs/setup/administrators/setup-notifications.md @@ -2,10 +2,56 @@ As mentioned in [External Integrations](../../getting-started/architecture.md#external-integrations), Hypha offers notifications via e-mail and/or Slack. -## Email +## Notification providers setup + +Hypha sends messages via configured providers such as email or Slack. + +### Email For information on configuring email notifications for Hypha, see the [E-mail section](configuration.md#e-mail-settings) of the configuration reference. -## Slack +### Slack For information on configuring Slack notifications for Hypha, see the [Slack section](configuration.md/#slack-settings) of the configuration reference. + +## Project report reminders + +To send reminders for upcoming due reports, configure the following three things. + +### Wagtail administration project settings for report reminders + +Visit Wagtail admin, click Projects -> Project Settings (or URL `/admin/settings/application_projects/projectsettings`). + +Scroll down to add "Report reminder frequency" configurations. Click "Add report reminder frequency" for each reminder. + +### Script to run `notify_report_due` command + +Create a script that runs the `django` manage command `notify_report_due`. + +For example, + +```shell +#!/bin/bash + +APP_ROOT=/opt/hypha +cd "${APP_ROOT}" || exit 1 +export DJANGO_SETTINGS_MODULE="hypha.settings.production" + +exec venv/hypha/bin/python3 manage.py notify_report_due +``` + +The example assumes Hypha is installed at `/opt/hypha`, uses a virtual environment `venv/hypha`, with production +settings. Adjust as needed for your environment and settings. Make sure that the settings has `SEND_MESSAGES = True`. + +### Mechanism to run the above script + +Create a regularly executing task that runs the above script. + +For example, + +```cron +37 9 * * * /usr/local/bin/send-report-reminders.sh +``` + +The example assumes `cron`, that the shell script calling `notify_report_due` is in `/usr/local/bin` and is executable, +and that the report reminders (when needed) should be sent at 9:37 am according to the system clock. diff --git a/hypha/apply/projects/management/commands/notify_report_due.py b/hypha/apply/projects/management/commands/notify_report_due.py index 80d14e67a6..ae8e27a5fd 100644 --- a/hypha/apply/projects/management/commands/notify_report_due.py +++ b/hypha/apply/projects/management/commands/notify_report_due.py @@ -6,16 +6,17 @@ from django.utils import timezone from hypha.apply.activity.messaging import MESSAGES, messenger -from hypha.apply.projects.models import Project +from hypha.apply.projects.models import ( + Project, + ProjectReminderFrequency, + ProjectSettings, +) from hypha.home.models import ApplyHomePage class Command(BaseCommand): help = "Notify users that they have a report due soon" - def add_arguments(self, parser): - parser.add_argument("days_before", type=int) - def handle(self, *args, **options): site = ApplyHomePage.objects.first().get_site() @@ -29,22 +30,41 @@ def handle(self, *args, **options): request._messages = FallbackStorage(request) today = timezone.now().date() - due_date = today + relativedelta(days=options["days_before"]) - for project in Project.objects.in_progress(): - next_report = project.report_config.current_due_report() - due_soon = next_report.end_date == due_date - not_notified_today = ( - not next_report.notified or next_report.notified.date() != today + + project_settings = ProjectSettings.objects.filter(site=site).first() + if not project_settings: + return + + for frequency in project_settings.reminder_frequencies.all(): + multiplier = ( + -1 + if frequency.relation + == ProjectReminderFrequency.FrequencyRelation.AFTER + else 1 ) - if due_soon and not_notified_today: - messenger( - MESSAGES.REPORT_NOTIFY, - request=request, - user=None, - source=project, - related=next_report, + delta = frequency.reminder_days * multiplier + + due_date = today + relativedelta(days=delta) + for project in Project.objects.in_progress(): + next_report = project.report_config.current_due_report() + if not next_report: + continue + + due_soon = next_report.end_date == due_date + not_notified_today = ( + not next_report.notified or next_report.notified.date() != today ) - # Notify about the due report - next_report.notified = timezone.now() - next_report.save() - self.stdout.write(self.style.SUCCESS(f"Notified project: {project.id}")) + if due_soon and not_notified_today: + messenger( + MESSAGES.REPORT_NOTIFY, + request=request, + user=None, + source=project, + related=next_report, + ) + # Notify about the due report + next_report.notified = timezone.now() + next_report.save() + self.stdout.write( + self.style.SUCCESS(f"Notified project: {project.id}") + ) diff --git a/hypha/apply/projects/migrations/0089_projectreminderfrequency.py b/hypha/apply/projects/migrations/0089_projectreminderfrequency.py new file mode 100644 index 0000000000..25cf6d334f --- /dev/null +++ b/hypha/apply/projects/migrations/0089_projectreminderfrequency.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.16 on 2024-10-18 11:24 + +from django.db import migrations, models +import django.db.models.deletion +import modelcluster.fields + + +class Migration(migrations.Migration): + dependencies = [ + ("application_projects", "0088_remove_duediligencedocument_vendor_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectReminderFrequency", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "sort_order", + models.IntegerField(blank=True, editable=False, null=True), + ), + ("reminder_days", models.IntegerField()), + ( + "relation", + models.CharField( + choices=[("BE", "Before"), ("AF", "After")], + default="BE", + max_length=2, + ), + ), + ( + "page", + modelcluster.fields.ParentalKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="reminder_frequencies", + to="application_projects.projectsettings", + ), + ), + ], + options={ + "ordering": ["sort_order"], + "abstract": False, + }, + ), + ] diff --git a/hypha/apply/projects/models/__init__.py b/hypha/apply/projects/models/__init__.py index cad3c39306..0fbeec492e 100644 --- a/hypha/apply/projects/models/__init__.py +++ b/hypha/apply/projects/models/__init__.py @@ -9,6 +9,7 @@ PAFApprovals, Project, ProjectForm, + ProjectReminderFrequency, ProjectReportForm, ProjectSettings, ProjectSOWForm, @@ -20,6 +21,7 @@ "ProjectForm", "ProjectReportForm", "ProjectSOWForm", + "ProjectReminderFrequency", "ProjectSettings", "PAFApprovals", "Contract", diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index f525056b4e..993ca5ebd1 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -19,7 +19,7 @@ from modelcluster.fields import ParentalKey, ParentalManyToManyField from modelcluster.models import ClusterableModel from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel -from wagtail.contrib.settings.models import BaseSiteSetting +from wagtail.contrib.settings.models import BaseSiteSetting, register_setting from wagtail.fields import StreamField from wagtail.models import Orderable @@ -488,6 +488,27 @@ def __str__(self): return str(self.label) +class ProjectReminderFrequency(Orderable, ClusterableModel): + reminder_days = models.IntegerField() + page = ParentalKey("ProjectSettings", related_name="reminder_frequencies") + + class FrequencyRelation(models.TextChoices): + BEFORE = "BE", _("Before") + AFTER = "AF", _("After") + + relation = models.CharField( + max_length=2, + choices=FrequencyRelation.choices, + default=FrequencyRelation.BEFORE, + ) + + panels = [ + FieldPanel("reminder_days", heading=_("Number of days")), + FieldPanel("relation", heading=_("Relation to report due date")), + ] + + +@register_setting class ProjectSettings(BaseSiteSetting, ClusterableModel): contracting_gp_email = models.TextField( "Contracting Group Email", null=True, blank=True @@ -518,6 +539,14 @@ class ProjectSettings(BaseSiteSetting, ClusterableModel): "to move all internal approval projects back to the 'Draft' stage with all approvals removed." ), ), + InlinePanel( + "reminder_frequencies", + label=_("Report reminder frequency"), + heading=_("Report reminder frequency"), + help_text=_( + "Set up a cron job to run `notify_report_due.py`. The script will use these reminder settings." + ), + ), ] diff --git a/hypha/apply/projects/tests/test_commands.py b/hypha/apply/projects/tests/test_commands.py index 18834cea65..d21e0bbbce 100644 --- a/hypha/apply/projects/tests/test_commands.py +++ b/hypha/apply/projects/tests/test_commands.py @@ -5,12 +5,30 @@ from django.test import TestCase from django.utils import timezone +from hypha.home.factories import ApplySiteFactory from hypha.home.models import ApplyHomePage +from ..models.project import ProjectReminderFrequency, ProjectSettings from .factories import ProjectFactory, ReportConfigFactory, ReportFactory class TestNotifyReportDue(TestCase): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + apply_site = ApplySiteFactory() + cls.project_settings, _ = ProjectSettings.objects.get_or_create( + site_id=apply_site.id, + ) + cls.project_settings.reminder_frequencies = [ + ProjectReminderFrequency.objects.create( + page=cls.project_settings, + reminder_days=7, + relation=ProjectReminderFrequency.FrequencyRelation.BEFORE, + ), + ] + cls.project_settings.save() + def test_notify_report_due_in_7_days(self): in_a_week = timezone.now() + relativedelta(days=7) ReportConfigFactory(schedule_start=in_a_week, project__in_progress=True) @@ -19,7 +37,7 @@ def test_notify_report_due_in_7_days(self): with self.settings( ALLOWED_HOSTS=[ApplyHomePage.objects.first().get_site().hostname] ): - call_command("notify_report_due", 7, stdout=out) + call_command("notify_report_due", stdout=out) self.assertIn("Notified project", out.getvalue()) def test_dont_notify_report_due_in_7_days_already_submitted(self): @@ -36,7 +54,7 @@ def test_dont_notify_report_due_in_7_days_already_submitted(self): with self.settings( ALLOWED_HOSTS=[ApplyHomePage.objects.first().get_site().hostname] ): - call_command("notify_report_due", 7, stdout=out) + call_command("notify_report_due", stdout=out) self.assertNotIn("Notified project", out.getvalue()) def test_dont_notify_already_notified(self): @@ -50,17 +68,17 @@ def test_dont_notify_already_notified(self): notified=timezone.now(), ) out = StringIO() - call_command("notify_report_due", 7, stdout=out) + call_command("notify_report_due", stdout=out) self.assertNotIn("Notified project", out.getvalue()) def test_dont_notify_project_not_in_progress(self): ProjectFactory() out = StringIO() - call_command("notify_report_due", 7, stdout=out) + call_command("notify_report_due", stdout=out) self.assertNotIn("Notified project", out.getvalue()) def test_dont_notify_project_complete(self): ProjectFactory(is_complete=True) out = StringIO() - call_command("notify_report_due", 7, stdout=out) + call_command("notify_report_due", stdout=out) self.assertNotIn("Notified project", out.getvalue())