From 17cbacd5f3083c96cc0fec1636c24f142e6b8ac8 Mon Sep 17 00:00:00 2001
From: Andreas Ellewsen <ae@uio.no>
Date: Tue, 4 Jan 2022 17:02:05 +0100
Subject: [PATCH] Notify sponsors about ending roles

The setup has five parts:
- a new email template type
- a new django-q task for notifying
- a management command for scheduling the task periodically
- a new settings variable NOTIFIER_LIMIT for controlling the number of
  days into the future the notifier script should use for end date of
  roles.
- a new settings variable NOTIFIER_SCHEDULE_TYPE for controlling how
  often the notifier task should be scheduled when using the management
  command.

Resolves: GREG-162
---
 greg/management/commands/role_end_notifier.py | 31 ++++++++++
 greg/tasks.py                                 | 57 +++++++++++++++++++
 greg/tests/conftest.py                        | 24 +++++++-
 greg/tests/test_tasks.py                      | 41 +++++++++++++
 gregsite/settings/base.py                     |  7 +++
 gregui/mailutils.py                           | 13 +++++
 .../0003_add_email_template_type.py           | 26 +++++++++
 gregui/models.py                              |  5 ++
 8 files changed, 203 insertions(+), 1 deletion(-)
 create mode 100644 greg/management/commands/role_end_notifier.py
 create mode 100644 greg/tasks.py
 create mode 100644 greg/tests/test_tasks.py
 create mode 100644 gregui/migrations/0003_add_email_template_type.py

diff --git a/greg/management/commands/role_end_notifier.py b/greg/management/commands/role_end_notifier.py
new file mode 100644
index 00000000..2692adde
--- /dev/null
+++ b/greg/management/commands/role_end_notifier.py
@@ -0,0 +1,31 @@
+"""
+Command for scheduling the django-q task for notifying sponsors of
+expiring roles.
+
+Instead of having a task that gets scheduled, we could have had a
+management command that was run as a cronjob. However, that would mean
+another cronjob
+"""
+
+import logging
+import logging.config
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django_q.tasks import schedule
+
+
+logging.config.dictConfig(settings.LOGGING)
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+    help = "Schedule notification of sponsors"
+
+    def handle(self, *args, **options):
+        logger.info("Scheduling role end notifier task...")
+        schedule(
+            func="tasks.notify_sponsors_roles_ending",
+            schedule_type=settings.NOTIFIER_SCHEDULE_TYPE,
+        )
+        logger.info("Role end notifier task scheduled.")
diff --git a/greg/tasks.py b/greg/tasks.py
new file mode 100644
index 00000000..46db90b6
--- /dev/null
+++ b/greg/tasks.py
@@ -0,0 +1,57 @@
+"""
+Collection of tasks designed to be run by django-q
+"""
+
+import datetime
+from collections import defaultdict
+
+from django.conf import settings
+
+from greg.models import Role, Sponsor
+from gregui import mailutils
+
+
+def notify_sponsors_roles_ending() -> None:
+    """
+    This task notifies sponsors of roles (that are accessible to them)
+    that are about to expire.
+
+    It is meant to be run at set intervals. Typically every week or
+    two. The script will notify of all roles ending between today and
+    the number of days set in the NOTIFIER_LIMIT setting.
+
+    While it is sensible to set the run interval to the same value as
+    the NOTIFIER_LIMIT, it is not mandatory, as there is a use case for
+    setting the run interval to e.g 1 week and the limit to 2. This
+    would ensure that the sponsor gets 2 notifications about the same
+    role ending. (Be careful not to swap them though, as that would
+    ensure some roles ending without a notification being sent to the
+    sponsor).
+    """
+    # Map ending roles to units
+    today = datetime.date.today()
+    ending_roles = Role.objects.filter(
+        end_date__gte=today,
+        end_date__lte=today + datetime.timedelta(days=settings.NOTIFIER_LIMIT),
+    )
+    unit2roles = defaultdict(list)
+    for role in ending_roles:
+        unit2roles[role.orgunit.id].append(role)
+
+    # Map sponsors with email to units
+    sponsors = Sponsor.objects.filter(work_email__isnull=False)
+    sp2unit = {s.id: list(s.get_allowed_units()) for s in sponsors}
+
+    # Map sponsors to ending roles
+    sp2roles = defaultdict(list)
+    for sp, units in sp2unit.items():
+        for unit in units:
+            for role in unit2roles[unit.id]:
+                sp2roles[sp].append(role)
+
+    # Send emails to sponsors
+    for sp, roles in sp2roles.items():
+        mailutils.send_role_ending_mail(
+            mail_to=sponsors.get(id=sp).work_email,  # type: ignore
+            num_roles=len(roles),
+        )
diff --git a/greg/tests/conftest.py b/greg/tests/conftest.py
index abeafcdf..db81a828 100644
--- a/greg/tests/conftest.py
+++ b/greg/tests/conftest.py
@@ -1,3 +1,4 @@
+import datetime
 import logging
 
 from rest_framework.authtoken.models import Token
@@ -115,7 +116,10 @@ def person_bar() -> Person:
 @pytest.fixture
 def sponsor_guy() -> Sponsor:
     sp = Sponsor.objects.create(
-        feide_id="guy@example.org", first_name="Sponsor", last_name="Guy"
+        feide_id="guy@example.org",
+        first_name="Sponsor",
+        last_name="Guy",
+        work_email="sponsor_guy@example.com",
     )
     return Sponsor.objects.get(id=sp.id)
 
@@ -183,6 +187,24 @@ def role_person_foo(
     return Role.objects.get(id=role.id)
 
 
+@pytest.fixture
+def role_person_foo2(
+    person_foo: Person,
+    role_type_test_guest: RoleType,
+    sponsor_guy: Sponsor,
+    unit_foo: OrganizationalUnit,
+) -> Role:
+    role = Role.objects.create(
+        person=person_foo,
+        type=role_type_test_guest,
+        start_date=datetime.date.today(),
+        end_date=datetime.date.today() + datetime.timedelta(days=5),
+        sponsor=sponsor_guy,
+        orgunit=unit_foo,
+    )
+    return Role.objects.get(id=role.id)
+
+
 @pytest.fixture
 def role_type_foo() -> RoleType:
     rt = RoleType.objects.create(identifier="role_foo", name_en="Role Foo")
diff --git a/greg/tests/test_tasks.py b/greg/tests/test_tasks.py
new file mode 100644
index 00000000..f35505b3
--- /dev/null
+++ b/greg/tests/test_tasks.py
@@ -0,0 +1,41 @@
+import pytest
+from django.core import mail
+
+from greg.tasks import notify_sponsors_roles_ending
+from gregui.models import EmailTemplate
+
+
+@pytest.fixture
+def role_end_reminder_template():
+    et = EmailTemplate.objects.create(
+        template_key=EmailTemplate.EmailType.ROLE_END_REMINDER,
+        subject="role end reminder",
+        body="""Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
+Du administrerer {{ num_roles }} gjesteroller som snart utløper.
+Dersom det er ønskelig å forlenge rollene kan dette gjøres i webgrensesnittet.
+
+This message has been automatically generated by the guest registration system.
+You can maintan {{ num_roles }} guest roles about to expire.
+To extend the guest role please use the web interface.""",
+    )
+    return EmailTemplate.objects.get(id=et.id)
+
+
+@pytest.mark.django_db
+def test_notify_sponsors_roles_ending(
+    role_end_reminder_template, role_person_foo2, sponsor_org_unit
+):
+    mail.outbox = []
+    notify_sponsors_roles_ending()
+    assert len(mail.outbox) == 1
+    assert mail.outbox[0].to == ["sponsor_guy@example.com"]
+    assert (
+        mail.outbox[0].body
+        == """Dette er en automatisk generert melding fra gjesteregistreringstjenesten.
+Du administrerer 1 gjesteroller som snart utløper.
+Dersom det er ønskelig å forlenge rollene kan dette gjøres i webgrensesnittet.
+
+This message has been automatically generated by the guest registration system.
+You can maintan 1 guest roles about to expire.
+To extend the guest role please use the web interface."""
+    )
diff --git a/gregsite/settings/base.py b/gregsite/settings/base.py
index 3f5b5289..811a7cd2 100644
--- a/gregsite/settings/base.py
+++ b/gregsite/settings/base.py
@@ -316,3 +316,10 @@ Q_CLUSTER = {
     "bulk": 10,
     "orm": "default",
 }
+
+# Number of days into the future to notify about ending roles. Used by the task that
+# notifies sponsors about ending roles.
+NOTIFIER_LIMIT = 7
+# Default schedule for the task notifying about ending roles. Used by the management
+# command that triggers the task.
+NOTIFIER_SCHEDULE_TYPE = "W"
diff --git a/gregui/mailutils.py b/gregui/mailutils.py
index e1902da2..3d47e0bb 100644
--- a/gregui/mailutils.py
+++ b/gregui/mailutils.py
@@ -60,6 +60,14 @@ def confirmation_template(guest: str, mail_to: str) -> dict[str, Union[str, list
     return prepare_arguments(template, context, mail_to)
 
 
+def reminder_template(mail_to: str, num_roles: int) -> dict[str, Union[str, list[str]]]:
+    template = EmailTemplate.objects.get(
+        template_key=EmailTemplate.EmailType.ROLE_END_REMINDER
+    )
+    context = Context({"num_roles": num_roles})
+    return prepare_arguments(template, context, mail_to)
+
+
 def send_registration_mail(mail_to: str, sponsor: str) -> str:
     arguments = registration_template(settings.INSTANCE_NAME, sponsor, mail_to)
     return async_task("django.core.mail.send_mail", **arguments)
@@ -70,6 +78,11 @@ def send_confirmation_mail(mail_to: str, guest: str) -> str:
     return async_task("django.core.mail.send_mail", **arguments)
 
 
+def send_role_ending_mail(mail_to: str, num_roles: int) -> str:
+    arguments = reminder_template(mail_to, num_roles)
+    return async_task("django.core.mail.send_mail", **arguments)
+
+
 def send_invite_mail(link: InvitationLink) -> Optional[str]:
     email_address = link.invitation.role.person.private_email
     if not email_address:
diff --git a/gregui/migrations/0003_add_email_template_type.py b/gregui/migrations/0003_add_email_template_type.py
new file mode 100644
index 00000000..77800099
--- /dev/null
+++ b/gregui/migrations/0003_add_email_template_type.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.0 on 2022-01-04 09:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("gregui", "0002_emailtemplate"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="emailtemplate",
+            name="template_key",
+            field=models.CharField(
+                choices=[
+                    ("guest_registration", "Guest Registration"),
+                    ("sponsor_confirmation", "Sponsor Confirmation"),
+                    ("role_end_reminder", "Role End Reminder"),
+                ],
+                max_length=64,
+                unique=True,
+            ),
+        ),
+    ]
diff --git a/gregui/models.py b/gregui/models.py
index 551b20f8..23de6d77 100644
--- a/gregui/models.py
+++ b/gregui/models.py
@@ -33,6 +33,10 @@ class EmailTemplate(BaseModel):
 
     Only one template of each type is allowed. To introduce new ones, simply add a new
     EmailType.
+
+    GUEST_REGISTRATION is for informing a guest that they have been invited
+    SPONSOR_CONFIRMATION is for informing the sponsor they must verify the guest's information
+    ROLE_END_REMINDER is used when reminding the sponsor if their ending roles in the near future
     """
 
     class EmailType(models.TextChoices):
@@ -40,6 +44,7 @@ class EmailTemplate(BaseModel):
 
         GUEST_REGISTRATION = "guest_registration"
         SPONSOR_CONFIRMATION = "sponsor_confirmation"
+        ROLE_END_REMINDER = "role_end_reminder"
 
     template_key = models.CharField(
         max_length=64, choices=EmailType.choices, unique=True
-- 
GitLab