diff --git a/greg/management/commands/start_schedule_tasks.py b/greg/management/commands/start_schedule_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..2280d56698c6886d88114a91a6c3790fe8fd8614 --- /dev/null +++ b/greg/management/commands/start_schedule_tasks.py @@ -0,0 +1,82 @@ +import logging +import signal +import sys +from threading import Event + +import daemon +import lockfile +from daemon import pidfile +from django.conf import settings +from django.core.management.base import BaseCommand + +from greg.schedule import ExpiringRolesNotification + +logging.config.dictConfig(settings.LOGGING) +logger = logging.getLogger() + + +def exception_handler(ex_cls, ex, tb): + logger.critical("Uncaught exception:", exc_info=(ex_cls, ex, tb)) + + +sys.excepthook = exception_handler + + +class Command(BaseCommand): + """ + This command starts a basic task runner. All tasks it is supposed to + run are given explicitly in code + """ + + help = "Start schedule task runner" + + def add_arguments(self, parser): + parser.add_argument( + "--detach", action="store_true", help="Run detached as a dæmon" + ) + parser.add_argument( + "--use-pidfile", action="store_true", help="Use a PID lockfile" + ) + + def handle(self, *args, **options): + lock_context = None + if options.get("use_pidfile"): + lock_context = pidfile.TimeoutPIDLockFile( + settings.SCHEDULE_TASKS["daemon"]["pid_file"] + ) + + try: + exit_event = Event() + + def exit_wrapper(*args): + exit_event.set() + + with daemon.DaemonContext( + pidfile=lock_context, + files_preserve=[x.stream.fileno() for x in logger.handlers], + stderr=sys.stderr, + stdout=sys.stdout, + signal_map={signal.SIGINT: exit_wrapper, signal.SIGTERM: exit_wrapper}, + detach_process=options.get("detach"), + ): + logger.info( + "Running %s", "detached" if options.get("detach") else "attached" + ) + + # For now there is just one task, but the idea is that more can + # be added + expiring_role_notification_task = ExpiringRolesNotification() + + while not exit_event.is_set(): + expiring_role_notification_task.run() + # The single task is set up for far only needs to run once + # a day, but running every 6 hours just in case a run fails, + # it is up the task it self to figure out if needs to do + # something every time it is called + exit_event.wait(60 * 60 * 6) + + except lockfile.AlreadyLocked as e: + logger.warning("Can't start daemon: %s", e) + sys.exit(1) + finally: + logger.info("Stopped") diff --git a/greg/management/commands/stop_schedule_tasks.py b/greg/management/commands/stop_schedule_tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..ba8af1cd1a9a35c6f0fa20f7de0d937e57395d19 --- /dev/null +++ b/greg/management/commands/stop_schedule_tasks.py @@ -0,0 +1,18 @@ +import os +import signal + +from django.conf import settings +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Stop schedule tasks" + + def handle(self, *args, **options): + try: + with open(settings.SCHEDULE_TASKS["daemon"]["pid_file"], "r") as f: + pid = int(f.read().strip()) + + os.kill(pid, signal.SIGINT) + except FileNotFoundError: + pass diff --git a/greg/migrations/0001_initial.py b/greg/migrations/0001_initial.py index 7504a4e320ce449103f0aa674c34a41fbef8f1da..fddea6eade9e3f3a9b3ddbedea4cecc4cb3d8f2b 100644 --- a/greg/migrations/0001_initial.py +++ b/greg/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.5 on 2021-08-04 11:07 +# Generated by Django 3.2.5 on 2021-08-06 10:47 import datetime import dirtyfields.dirtyfields @@ -104,6 +104,14 @@ class Migration(migrations.Migration): }, bases=(dirtyfields.dirtyfields.DirtyFieldsMixin, models.Model), ), + migrations.CreateModel( + name='ScheduleTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=32)), + ('last_completed', models.DateTimeField(null=True)), + ], + ), migrations.CreateModel( name='Sponsor', fields=[ diff --git a/greg/models.py b/greg/models.py index 73e5369760975e089e7f9f2243996f6d402260ef..a0db3da1a55c194ed4d5b04f52af2c7ed3c37239 100644 --- a/greg/models.py +++ b/greg/models.py @@ -299,3 +299,12 @@ class SponsorOrganizationalUnit(BaseModel): self.organizational_unit, self.hierarchical_access, ) + + +class ScheduleTask(models.Model): + """ + Stores information about a task + """ + + name = models.CharField(max_length=32) + last_completed = models.DateTimeField(null=True) diff --git a/greg/schedule.py b/greg/schedule.py new file mode 100644 index 0000000000000000000000000000000000000000..e9a907e36be35fd8c55d6ff7fc215c468c01731d --- /dev/null +++ b/greg/schedule.py @@ -0,0 +1,89 @@ +import time +from datetime import date, datetime, timedelta +from typing import Optional + +from abc import ABC, abstractmethod +from django.utils import timezone + +from greg.models import PersonRole, Notification, ScheduleTask + + +class BaseSchedule(ABC): + """ + Provides common methods for tasks + """ + + task_object: ScheduleTask + + def __init__(self, name: str): + self.task_object = ScheduleTask.objects.get(name=name) + + def run(self): + self._run_internal() + self.task_object.last_completed = timezone.now() + self.task_object.save() + + @abstractmethod + def _run_internal(self): + pass + + def get_last_run(self) -> Optional[datetime]: + self.task_object.refresh_from_db() + return self.task_object.last_completed + + def _store_notification( + self, identifier, object_type, operation, **kwargs + ) -> Notification: + return Notification.objects.create( + identifier=identifier, + object_type=object_type, + operation=operation, + issued_at=int(time.time()), + meta=kwargs, + ) + + +class ExpiringRolesNotification(BaseSchedule): + """ + This task does a simple check for roles that will expire in 30 days + and creates entries in the notification table for them. + + There should be an entry in the ScheduleTask-table with name role_expiration + before this task is run. + + Some assumptions that are made: + + - The task will be run every day, it does + not keep track of which roles it has or has not notified, it only + looks at which roles will expire in exactly 30 days + + - If there are roles created that expire in less than 30 days, no + notification is necessary + """ + + EXPIRATION_THRESHOLD_DAYS = 30 + + def __init__(self): + # Will raise exception if there is not exactly one result + super().__init__("role_expiration") + + def _run_internal(self): + last_run = self.get_last_run() + # Only run once per day + if last_run is None or last_run.date() != date.today(): + check_date = datetime.today() + timedelta( + days=self.EXPIRATION_THRESHOLD_DAYS + ) + self.__get_roles_about_to_expire(check_date) + + def __get_roles_about_to_expire(self, end_date: date): + roles_about_to_expire = PersonRole.objects.filter(end_date=end_date) + + for person_role in roles_about_to_expire: + meta = {"person_id": person_role.person.id, "role_id": person_role.role.id} + self._store_notification( + identifier=person_role.id, + object_type="PersonRole", + operation="expire_reminder", + **meta + ) diff --git a/greg/tests/test_expire_role.py b/greg/tests/test_expire_role.py new file mode 100644 index 0000000000000000000000000000000000000000..d116eb92c8342fc4019872cca27dad4e00254116 --- /dev/null +++ b/greg/tests/test_expire_role.py @@ -0,0 +1,94 @@ +from datetime import datetime, timedelta + +import pytest +from django.db.models import Q +from django.utils import timezone + +from greg.models import ( + ScheduleTask, + Role, + Person, + OrganizationalUnit, + PersonRole, + Notification, + Sponsor, +) +from greg.schedule import ExpiringRolesNotification + + +@pytest.fixture +def role_task(): + ScheduleTask.objects.create( + name="role_expiration", last_completed=timezone.now() - timedelta(days=1) + ) + + +@pytest.fixture +def role_bar() -> Role: + return Role.objects.create(type="role_bar", name_en="Role Bar") + + +@pytest.fixture +def person(role_bar: Role) -> Person: + return Person.objects.create( + first_name="Test", + last_name="Tester", + date_of_birth="2000-01-27", + email="test@example.org", + mobile_phone="123456789", + ) + + +@pytest.fixture +def organizational_unit() -> OrganizationalUnit: + return OrganizationalUnit.objects.create(orgreg_id="12345", name_en="Test unit") + + +@pytest.fixture +def sponsor() -> Sponsor: + return Sponsor.objects.create(feide_id="sponsor@example.org") + + +@pytest.fixture +def person_role( + person: Person, + role_bar: Role, + organizational_unit: OrganizationalUnit, + sponsor: Sponsor, +) -> PersonRole: + return PersonRole.objects.create( + person=person, + role=role_bar, + unit=organizational_unit, + start_date="2020-03-05", + end_date=datetime.today() + timedelta(days=30), + contact_person_unit="Contact Person", + available_in_search=True, + registered_by=sponsor, + ) + + +@pytest.mark.django_db +def test_role_picked_up(role_task: ScheduleTask, person_role: PersonRole): + role_notification = ExpiringRolesNotification() + assert len(Notification.objects.filter(~Q(operation="add"))) == 0 + role_notification.run() + notification = Notification.objects.get(operation="expire_reminder") + assert notification.identifier == person_role.id + role_notification.run() + notifications = Notification.objects.filter(operation="expire_reminder") + assert len(notifications) == 1 + + +@pytest.mark.django_db +def test_no_notification_for_role_not_about_to_expire( + role_task: ScheduleTask, person_role: PersonRole +): + person_role.end_date = datetime.today() + timedelta(days=31) + person_role.save() + role_notification = ExpiringRolesNotification() + assert len(Notification.objects.filter(operation="expire_reminder")) == 0 + # Role should not be picked up since it expires in 31 days + role_notification.run() + notifications = Notification.objects.filter(operation="expire_reminder") + assert len(notifications) == 0 diff --git a/greg/tests/test_notifications.py b/greg/tests/test_notifications.py index 8172a2be5cf1668a39a8f848bd62e420f349730f..584858f9337cbf6b6054c9ddce946ddb57095232 100644 --- a/greg/tests/test_notifications.py +++ b/greg/tests/test_notifications.py @@ -55,7 +55,7 @@ def consent() -> Consent: def person_identity(person: Person) -> PersonIdentity: return PersonIdentity.objects.create( person=person, - type=PersonIdentity.IdentityType.PASSPORT_NUMBER, + type=PersonIdentity.IdentityType.PASSPORT, source="Test", value="12345678901", ) diff --git a/gregsite/settings/base.py b/gregsite/settings/base.py index 62e8a4920607e33cc61e1a1ee4a34b3e18d92a01..fb28c3b132d1a5df72cc2eeb3abe08bc02b9ca4b 100644 --- a/gregsite/settings/base.py +++ b/gregsite/settings/base.py @@ -206,6 +206,10 @@ NOTIFICATION_PUBLISHER = { "daemon": {"pid_file": "/tmp/greg_notification_publisher.lock", "poll_interval": 1}, } +SCHEDULE_TASKS = { + "daemon": {"pid_file": "/tmp/schedule_tasks.lock"}, +} + INSTANCE_NAME = "local" INTERNAL_RK_PREFIX = "no.{instance}.greg".format(instance=INSTANCE_NAME)