From 2db65f96cb8c227c54b443116e3735fb60285406 Mon Sep 17 00:00:00 2001 From: Andreas Ellewsen <ae@uio.no> Date: Mon, 8 Nov 2021 16:33:40 +0100 Subject: [PATCH] Add future notifications for roles The post_save signal now checks if the instance is a Role, and queues a django q task to produce a Notification on the start and end dates for the Role. The future task checks if the date has changed after it was queued and only produces a Notification if today's date matches the date for the task. Resolves: GREG-65 --- greg/admin.py | 14 ++++++++ greg/signals.py | 58 +++++++++++++++++++++++++++++--- greg/tests/test_notifications.py | 9 +++-- greg/tests/test_signals.py | 52 +++++++++++++++++++++++++++- greg/utils.py | 14 +++++++- mypy.ini | 3 ++ 6 files changed, 140 insertions(+), 10 deletions(-) diff --git a/greg/admin.py b/greg/admin.py index 809f5bfb..c19389dc 100644 --- a/greg/admin.py +++ b/greg/admin.py @@ -5,6 +5,7 @@ from greg.models import ( OuIdentifier, Invitation, InvitationLink, + Notification, Person, Role, RoleType, @@ -133,6 +134,18 @@ class InvitationLinkAdmin(VersionAdmin): readonly_fields = ("uuid",) +class NotificationAdmin(VersionAdmin): + list_display = ( + "id", + "object_type", + "identifier", + "operation", + "created", + "updated", + "issued_at", + ) + + admin.site.register(Person, PersonAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(RoleType, RoleTypeAdmin) @@ -145,3 +158,4 @@ admin.site.register(SponsorOrganizationalUnit, SponsorOrganizationalUnitAdmin) admin.site.register(Invitation, InvitationAdmin) admin.site.register(InvitationLink, InvitationLinkAdmin) admin.site.register(OuIdentifier, IdentifierAdmin) +admin.site.register(Notification, NotificationAdmin) diff --git a/greg/signals.py b/greg/signals.py index 3f52d861..9cf2a8ea 100644 --- a/greg/signals.py +++ b/greg/signals.py @@ -1,10 +1,11 @@ +import datetime import time import logging from typing import Dict, Union from django.db import models from django.dispatch import receiver - +from django_q.models import Schedule from greg.models import ( Person, Role, @@ -14,6 +15,7 @@ from greg.models import ( Consent, ConsentType, ) +from greg.utils import date_to_datetime_midnight logger = logging.getLogger(__name__) @@ -74,6 +76,37 @@ def _store_notification(identifier, object_type, operation, **kwargs): ) +def _queue_role_notification(role_id: int, created: str, attrname: str): + """Create a notification if the date in attrname of role is today""" + try: + instance = Role.objects.get(id=role_id) + except Role.DoesNotExist: + # Role was deleted, ignore it + return + if getattr(instance, attrname) != datetime.date.today(): + # Date was changed after this task was queued, ignore it, the new one will take + # care of it + return + meta = _create_metadata(instance) + operation = "add" if created else "update" + _store_notification( + identifier=instance.id, + object_type=instance._meta.object_name, + operation=operation, + **meta, + ) + + +def _queue_role_start_notification(role_id: int, created: str): + """Create a notification for the role if start_date is today""" + _queue_role_notification(role_id, created, "start_date") + + +def _queue_role_end_notification(role_id: int, created: str): + """Create a notification for the role if end_date is today""" + _queue_role_notification(role_id, created, "end_date") + + @receiver(models.signals.pre_save, dispatch_uid="add_changed_fields_callback") def add_changed_fields_callback(sender, instance, raw, *args, **kwargs): """ @@ -94,13 +127,28 @@ def add_changed_fields_callback(sender, instance, raw, *args, **kwargs): def save_notification_callback(sender, instance, created, *args, **kwargs): if not isinstance(instance, SUPPORTED_MODELS): return + # Queue future notifications on start and end date for roles + if isinstance(instance, Role): + if instance.start_date: + Schedule.objects.create( + func="greg.signals._queue_role_start_notification", + args=f"{instance.id},True", + next_run=date_to_datetime_midnight(instance.start_date), + schedule_type=Schedule.ONCE, + ) + Schedule.objects.create( + func="greg.signals._queue_role_end_notification", + args=f"{instance.id},True", + next_run=date_to_datetime_midnight(instance.end_date), + schedule_type=Schedule.ONCE, + ) meta = _create_metadata(instance) operation = "add" if created else "update" _store_notification( identifier=instance.id, object_type=instance._meta.object_name, operation=operation, - **meta + **meta, ) @@ -113,7 +161,7 @@ def delete_notification_callback(sender, instance, *args, **kwargs): identifier=instance.id, object_type=instance._meta.object_name, operation="delete", - **meta + **meta, ) @@ -146,7 +194,7 @@ def m2m_changed_notification_callback( identifier=pc.id, object_type=Consent._meta.object_name, operation=operation, - **meta + **meta, ) elif sender is Role: roles = [] @@ -161,7 +209,7 @@ def m2m_changed_notification_callback( identifier=pr.id, object_type=Role._meta.object_name, operation=operation, - **meta + **meta, ) diff --git a/greg/tests/test_notifications.py b/greg/tests/test_notifications.py index e5b68d0a..c0fd917d 100644 --- a/greg/tests/test_notifications.py +++ b/greg/tests/test_notifications.py @@ -15,22 +15,25 @@ from greg.models import ( @pytest.fixture def org_unit_bar() -> OrganizationalUnit: - return OrganizationalUnit.objects.create() + org = OrganizationalUnit.objects.create() + return OrganizationalUnit.objects.get(pk=org.id) @pytest.fixture def sponsor() -> Sponsor: - return Sponsor.objects.create(feide_id="sponsor_id") + sp = Sponsor.objects.create(feide_id="sponsor_id") + return Sponsor.objects.get(pk=sp.id) @pytest.fixture def identity(person: Person) -> Identity: - return Identity.objects.create( + ident = Identity.objects.create( person=person, type=Identity.IdentityType.PASSPORT_NUMBER, source="Test", value="12345678901", ) + return Identity.objects.get(pk=ident.id) @pytest.mark.django_db diff --git a/greg/tests/test_signals.py b/greg/tests/test_signals.py index 1570fd26..31e4da47 100644 --- a/greg/tests/test_signals.py +++ b/greg/tests/test_signals.py @@ -1,6 +1,22 @@ +import datetime import pytest -from greg.models import Notification +from greg.models import Notification, Role +from greg.signals import _queue_role_start_notification, _queue_role_end_notification + + +@pytest.fixture +def role_today(person, role_type_test_guest, sponsor_guy, unit_foo): + """A test role with end and start date today.""" + role = Role.objects.create( + person=person, + type=role_type_test_guest, + sponsor=sponsor_guy, + orgunit=unit_foo, + start_date=datetime.date.today(), + end_date=datetime.date.today(), + ) + return Role.objects.get(pk=role.id) @pytest.mark.django_db @@ -17,3 +33,37 @@ def test_delete_signal_ou(unit_foo): assert Notification.objects.count() == 0 unit_foo.delete() assert Notification.objects.count() == 0 + + +@pytest.mark.django_db +def test_queue_role_start_notification(role_today): + """Check that a notification is produced if the role starts today""" + assert Notification.objects.all().count() == 3 + _queue_role_start_notification(role_today.id, True) + assert Notification.objects.all().count() == 4 + + +@pytest.mark.django_db +def test_queue_role_end_notification(role_today): + """Check that a notification is produced if the role ends today""" + assert Notification.objects.all().count() == 3 + _queue_role_end_notification(role_today.id, True) + assert Notification.objects.all().count() == 4 + + +@pytest.mark.django_db +def test_queue_role_end_notification_wrong_date(role_today): + """Check that a notification is produced if the role ends today""" + role_today.end_date = datetime.date.today() - datetime.timedelta(days=2) + role_today.save() + assert Notification.objects.all().count() == 4 + _queue_role_end_notification(role_today.id, True) + assert Notification.objects.all().count() == 4 + + +@pytest.mark.django_db +def test_queue_role_end_notification_role_deleted(): + """Check that a notification is not produced if the role was deleted""" + assert Notification.objects.all().count() == 0 + _queue_role_end_notification(10, True) + assert Notification.objects.all().count() == 0 diff --git a/greg/utils.py b/greg/utils.py index 8408087a..a7a27171 100644 --- a/greg/utils.py +++ b/greg/utils.py @@ -1,5 +1,8 @@ import re -from datetime import date +import typing +from datetime import date, datetime + +from django.utils import timezone def camel_to_snake(s: str) -> str: @@ -90,3 +93,12 @@ def _compute_checksum(input_digits: str) -> bool: k2 = 0 return k1 < 10 and k2 < 10 and k1 == d[9] and k2 == d[10] + + +def date_to_datetime_midnight(in_date: typing.Union[date, str]) -> datetime: + """Convert a date or str object to a datetime object with timezone utc""" + start_date = ( + datetime.strptime(in_date, "%Y-%M-%d") if isinstance(in_date, str) else in_date + ) + start = datetime.combine(start_date, datetime.min.time(), tzinfo=timezone.utc) + return start diff --git a/mypy.ini b/mypy.ini index 4b3066fc..9b205574 100644 --- a/mypy.ini +++ b/mypy.ini @@ -20,6 +20,9 @@ ignore_missing_imports = True [mypy-django_extensions.db.fields] ignore_missing_imports = True +[mypy-django_q.*] +ignore_missing_imports = True + [mypy-django_filters.*] ignore_missing_imports = True -- GitLab