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