diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 50e97ac8e33aef22a29100f193eda99457341969..b9bde6e8cf6c44c4be6cb4086c7650b457b7779f 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -14,7 +14,7 @@ "nationalIdNumber": "National ID number", "roleType": "Role", "roleStartDate": "From", - "roleEndDate": "To", + "roleEndDate": "To (including)", "comment": "Comment", "searchable": "Available in search?", "email": "E-mail", diff --git a/frontend/public/locales/nb/common.json b/frontend/public/locales/nb/common.json index a6849c3e66d272b502822488e32476761ff926fe..e107a8bbd58f15cea85200281557972d772be449 100644 --- a/frontend/public/locales/nb/common.json +++ b/frontend/public/locales/nb/common.json @@ -14,7 +14,7 @@ "nationalIdNumber": "Fødselsnummer/D-nummer", "roleType": "Gjesterolle", "roleStartDate": "Fra", - "roleEndDate": "Til", + "roleEndDate": "Til og med", "comment": "Kommentar", "searchable": "Synlig i søk?", "email": "E-post", diff --git a/frontend/public/locales/nn/common.json b/frontend/public/locales/nn/common.json index aa5b64c7794bf4302d990d0596e79edb664bb39b..b689424eb9ade20158279f3bfa0e42923f2393fe 100644 --- a/frontend/public/locales/nn/common.json +++ b/frontend/public/locales/nn/common.json @@ -14,7 +14,7 @@ "nationalIdNumber": "Fødselsnummer/D-nummer", "roleType": "Gjesterolle", "roleStartDate": "Frå", - "roleEndDate": "Til", + "roleEndDate": "Til og med", "comment": "Kommentar", "searchable": "Synleg i søk?", "email": "E-post", diff --git a/greg/signals.py b/greg/signals.py index de65ab3e8c62b15e960b358201c094eacdaccd04..cedcf0b1aded1ad3ac5278130b26c13d19acc9f8 100644 --- a/greg/signals.py +++ b/greg/signals.py @@ -1,6 +1,6 @@ import datetime import time -from typing import Dict, Union +from typing import Callable, Dict, Union from django_structlog.signals import bind_extra_request_metadata from django.db import models @@ -15,7 +15,7 @@ from greg.models import ( Consent, ConsentType, ) -from greg.utils import date_to_datetime_midnight +from greg.utils import date_to_datetime_midnight, str2date @receiver(bind_extra_request_metadata) @@ -81,16 +81,18 @@ 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""" +def _queue_role_notification( + role_id: int, + created: str, + should_notify: Callable[[Role], bool], +): + """Create a notification if provided function says so""" 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 + if not should_notify(instance): return meta = _create_metadata(instance) operation = "add" if created else "update" @@ -104,12 +106,20 @@ def _queue_role_notification(role_id: int, created: str, attrname: str): 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 should_notify(instance): + return instance.start_date == datetime.date.today() + + _queue_role_notification(role_id, created, should_notify) 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") + """Create a notification for the role if end_date was yesterday""" + + def should_notify(instance: Role): + return instance.end_date == datetime.date.today() - datetime.timedelta(days=1) + + _queue_role_notification(role_id, created, should_notify) @receiver(models.signals.pre_save, dispatch_uid="add_changed_fields_callback") @@ -128,34 +138,49 @@ def add_changed_fields_callback(sender, instance, raw, *args, **kwargs): ) +def _handle_role_save_signal(instance: Role): + """ + Side effects of saving a Role + + Schedules the production of a Notification on the start date and on + the day following the end date of the Role if their dates are in + the future + """ + today = datetime.date.today() + if ( + "start_date" in instance._changed_fields # pylint: disable=protected-access + and instance.start_date + and str2date(instance.start_date) > today + ): + Schedule.objects.create( + func="greg.signals._queue_role_start_notification", + args=f"{instance.id},True", + next_run=date_to_datetime_midnight(str2date(instance.start_date)), + schedule_type=Schedule.ONCE, + ) + if ( + "end_date" in instance._changed_fields # pylint: disable=protected-access + and str2date(instance.end_date) > today + ): + Schedule.objects.create( + func="greg.signals._queue_role_end_notification", + args=f"{instance.id},True", + next_run=date_to_datetime_midnight( + str2date(instance.end_date) + datetime.timedelta(days=1) + ), + schedule_type=Schedule.ONCE, + ) + + @receiver(models.signals.post_save, dispatch_uid="save_notification_callback") def save_notification_callback(sender, instance, created, *args, **kwargs): + """Do things when supported models are saved""" if not isinstance(instance, SUPPORTED_MODELS): return # Queue future notifications on start and end date for roles if isinstance(instance, Role) and hasattr(instance, "_changed_fields"): - today = datetime.date.today() - if ( - "start_date" in instance._changed_fields # pylint: disable=protected-access - and instance.start_date - and instance.start_date != today - ): - 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, - ) - if ( - "end_date" in instance._changed_fields # pylint: disable=protected-access - and instance.end_date != today - ): - 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, - ) + _handle_role_save_signal(instance) + meta = _create_metadata(instance) operation = "add" if created else "update" _store_notification( diff --git a/greg/tests/test_signals.py b/greg/tests/test_signals.py index 31e4da470a1f3503e152e3f432ef4ea5bdf27f45..b7b4731cb78caf65f92e7bb3fc0d134607a9d799 100644 --- a/greg/tests/test_signals.py +++ b/greg/tests/test_signals.py @@ -1,6 +1,8 @@ import datetime import pytest +from django_q.models import Schedule + from greg.models import Notification, Role from greg.signals import _queue_role_start_notification, _queue_role_end_notification @@ -45,20 +47,46 @@ def test_queue_role_start_notification(role_today): @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) + """Check that a notification is produced if the role ends yesterday""" + role_today.end_date = datetime.date.today() - datetime.timedelta(days=1) + role_today.save() assert Notification.objects.all().count() == 4 + _queue_role_end_notification(role_today.id, True) + assert Notification.objects.all().count() == 5 @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) +def test_role_save_schedules_notifications(role_today): + """Verify that future end or start dates schedule future notifications""" + oneday = datetime.timedelta(days=1) + today = datetime.date.today() + tomorrow = today + oneday + yesterday = today - oneday + assert Notification.objects.all().count() == 3 + assert Schedule.objects.all().count() == 0 + + # Future end date schedules + role_today.end_date = tomorrow role_today.save() - assert Notification.objects.all().count() == 4 + assert Schedule.objects.all().count() == 1 + # Future start date schedules + role_today.start_date = tomorrow + role_today.save() + assert Schedule.objects.all().count() == 2 + # Past end date does not schedule (should be impossible in the application but test anyway) + role_today.end_date = yesterday + role_today.save() + assert Schedule.objects.all().count() == 2 # Past start date schedules + role_today.start_date = yesterday + role_today.save() + + +@pytest.mark.django_db +def test_queue_role_end_notification_wrong_date(role_today): + """Check that a notification is not produced if the role does not end yesterday""" + assert Notification.objects.all().count() == 3 _queue_role_end_notification(role_today.id, True) - assert Notification.objects.all().count() == 4 + assert Notification.objects.all().count() == 3 @pytest.mark.django_db diff --git a/greg/utils.py b/greg/utils.py index b8d7af1db2b17aa15f2f01e715780f6933cacf25..1afb6f20868692dc182eafb561eb94b0df43dbae 100644 --- a/greg/utils.py +++ b/greg/utils.py @@ -105,13 +105,13 @@ def _compute_checksum(input_digits: str) -> bool: 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 +def date_to_datetime_midnight(in_date: date) -> datetime: + """Convert a date object to a datetime object with timezone utc""" + return datetime.combine(in_date, datetime.min.time(), tzinfo=timezone.utc) + + +def str2date(in_date: typing.Union[str, date]): + return date.fromisoformat(in_date) if isinstance(in_date, str) else in_date def get_default_invitation_expire_date_from_now():