Skip to content

Commit

Permalink
Report reminders (#3990)
Browse files Browse the repository at this point in the history
Fixes #3989


Co-authored-by: Frank Duncan <[email protected]>
Co-authored-by: Fredrik Jonsson <[email protected]>
  • Loading branch information
3 people authored Nov 1, 2024
1 parent 9153972 commit 05f2a43
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 29 deletions.
50 changes: 48 additions & 2 deletions docs/setup/administrators/setup-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
62 changes: 41 additions & 21 deletions hypha/apply/projects/management/commands/notify_report_due.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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}")
)
53 changes: 53 additions & 0 deletions hypha/apply/projects/migrations/0089_projectreminderfrequency.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
2 changes: 2 additions & 0 deletions hypha/apply/projects/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
PAFApprovals,
Project,
ProjectForm,
ProjectReminderFrequency,
ProjectReportForm,
ProjectSettings,
ProjectSOWForm,
Expand All @@ -20,6 +21,7 @@
"ProjectForm",
"ProjectReportForm",
"ProjectSOWForm",
"ProjectReminderFrequency",
"ProjectSettings",
"PAFApprovals",
"Contract",
Expand Down
31 changes: 30 additions & 1 deletion hypha/apply/projects/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
),
),
]


Expand Down
28 changes: 23 additions & 5 deletions hypha/apply/projects/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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())

0 comments on commit 05f2a43

Please sign in to comment.