diff --git a/aleksis/apps/chronos/apps.py b/aleksis/apps/chronos/apps.py index 698cc3b9b71c327d0c0022d365b3ba4a80b7dbec..862b59f7520e76514fcf18766ea4223a1aa67fd7 100644 --- a/aleksis/apps/chronos/apps.py +++ b/aleksis/apps/chronos/apps.py @@ -1,3 +1,7 @@ +from django.db import transaction + +from reversion.signals import post_revision_commit + from aleksis.core.util.apps import AppConfig @@ -18,3 +22,13 @@ class ChronosConfig(AppConfig): ([2019], "Tom Teichler", "tom.teichler@teckids.org"), ([2021], "Lloyd Meins", "meinsll@katharineum.de"), ) + + def ready(self): + super().ready() + + from .util.change_tracker import handle_new_revision # noqa + + def _handle_post_revision_commit(sender, revision, versions, **kwargs): + transaction.on_commit(lambda: handle_new_revision.delay(revision.pk)) + + post_revision_commit.connect(_handle_post_revision_commit) diff --git a/aleksis/apps/chronos/migrations/0009_automatic_plan.py b/aleksis/apps/chronos/migrations/0009_automatic_plan.py new file mode 100644 index 0000000000000000000000000000000000000000..157ba3e2dbff894d162833ea1da142f0e6621cd3 --- /dev/null +++ b/aleksis/apps/chronos/migrations/0009_automatic_plan.py @@ -0,0 +1,45 @@ +# Generated by Django 3.2.4 on 2021-07-23 19:48 + +import django.contrib.sites.managers +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('reversion', '0001_squashed_0004_auto_20160611_1202'), + ('sites', '0002_alter_domain_unique'), + ('chronos', '0008_unique_constraints'), + ] + + operations = [ + migrations.CreateModel( + name='AutomaticPlan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('slug', models.SlugField(help_text='This will be used for the name of the PDF file with the generated plan.', verbose_name='Slug')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('number_of_days', models.PositiveIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)], verbose_name='Number of days shown in the plan')), + ('show_header_box', models.BooleanField(default=True, help_text='The header box shows affected teachers/groups.', verbose_name='Show header box')), + ('last_update', models.DateTimeField(blank=True, null=True, verbose_name='Date and time of the last update')), + ('last_update_triggered_manually', models.BooleanField(default=False, verbose_name='Was the last update triggered manually?')), + ('current_file', models.FileField(blank=True, null=True, upload_to='chronos/plan_pdfs/', verbose_name='Current file')), + ('last_revision', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='reversion.revision', verbose_name='Revision which triggered the last update')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Automatic plan', + 'verbose_name_plural': 'Automatic plans', + }, + managers=[ + ('objects', django.contrib.sites.managers.CurrentSiteManager()), + ], + ), + migrations.AddConstraint( + model_name='automaticplan', + constraint=models.UniqueConstraint(fields=('site_id', 'slug'), name='site_slug'), + ), + ] diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 07dc3fda0bd8429f7c335093bbe0b2ae3a07b723..6ef485cd304cc29bfdf350dd75b99870abbee828 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -3,13 +3,18 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta -from typing import Dict, Iterator, List, Optional, Tuple, Union +from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile, File +from django.core.files.storage import default_storage +from django.core.validators import MinValueValidator from django.db import models from django.db.models import Max, Min, Q from django.db.models.functions import Coalesce +from django.dispatch import receiver from django.forms import Media +from django.template.loader import render_to_string from django.urls import reverse from django.utils import timezone from django.utils.formats import date_format @@ -18,7 +23,10 @@ from django.utils.translation import gettext_lazy as _ from cache_memoize import cache_memoize from calendarweek.django import CalendarWeek, i18n_day_abbr_choices_lazy, i18n_day_name_choices_lazy +from celery.result import allow_join_result +from celery.states import SUCCESS from colorfield.fields import ColorField +from reversion.models import Revision, Version from aleksis.apps.chronos.managers import ( AbsenceQuerySet, @@ -45,6 +53,7 @@ from aleksis.apps.chronos.mixins import ( WeekAnnotationMixin, WeekRelatedMixin, ) +from aleksis.apps.chronos.util.change_tracker import substitutions_changed from aleksis.apps.chronos.util.date import get_current_year from aleksis.apps.chronos.util.format import format_m2m from aleksis.core.managers import CurrentSiteManagerWithoutMigrations @@ -53,8 +62,9 @@ from aleksis.core.mixins import ( GlobalPermissionModel, SchoolTermRelatedExtensibleModel, ) -from aleksis.core.models import DashboardWidget, SchoolTerm +from aleksis.core.models import DashboardWidget, PDFFile, SchoolTerm from aleksis.core.util.core_helpers import has_person +from aleksis.core.util.pdf import generate_pdf_from_template class ValidityRange(ExtensibleModel): @@ -1111,6 +1121,143 @@ class ExtraLesson( indexes = [models.Index(fields=["week", "year"], name="extra_lesson_week_year")] +@receiver(substitutions_changed) +def automatic_plan_signal_receiver(sender: Revision, versions: Iterable[Version], **kwargs): + """Check all automatic plans for updates after substitutions changed.""" + for automatic_plan in AutomaticPlan.objects.all(): + automatic_plan.check_update(sender, versions) + + +class AutomaticPlan(ExtensibleModel): + slug = models.SlugField( + verbose_name=_("Slug"), + help_text=_("This will be used for the name of the PDF file with the generated plan."), + ) + name = models.CharField(max_length=255, verbose_name=_("Name")) + number_of_days = models.PositiveIntegerField( + default=1, + validators=[MinValueValidator(1)], + verbose_name=_("Number of days shown in the plan"), + ) + show_header_box = models.BooleanField( + default=True, + verbose_name=_("Show header box"), + help_text=_("The header box shows affected teachers/groups."), + ) + + last_revision = models.ForeignKey( + to=Revision, + on_delete=models.SET_NULL, + blank=True, + null=True, + verbose_name=_("Revision which triggered the last update"), + ) + last_update = models.DateTimeField( + blank=True, null=True, verbose_name=_("Date and time of the last update") + ) + last_update_triggered_manually = models.BooleanField( + default=False, verbose_name=_("Was the last update triggered manually?") + ) + + current_file = models.FileField( + upload_to="chronos/plan_pdfs/", null=True, blank=True, verbose_name=_("Current file") + ) + + @property + def current_start_day(self) -> date: + """Get first day which should be shown in the PDF.""" + return TimePeriod.get_next_relevant_day(timezone.now().date(), datetime.now().time()) + + @property + def current_end_day(self) -> date: + """Get last day which should be shown in the PDF.""" + return self.current_start_day + timedelta(days=self.number_of_days - 1) + + def get_context_data(self) -> Dict[str, Any]: + """Get context data for generating the substitutions PDF.""" + from aleksis.apps.chronos.views import get_substitutions_context_data + + context = get_substitutions_context_data( + request=None, + is_print=True, + number_of_days=self.number_of_days, + show_header_box=self.show_header_box, + ) + + return context + + def check_update(self, revision: Revision, versions: Iterable[Version]): + """Check if the PDF file has to be updated and do the update then.""" + if not self.last_revision or ( + self.last_revision != revision + and revision.date_created > self.last_revision.date_created + ): + update = False + for version in versions: + # Check if the changed object is relevant for the time period of the PDF file + if isinstance(version.object, Event): + date_start = version.object.date_start + date_end = version.object.date_end + else: + date_start = date_end = version.object.date + if date_start <= self.current_end_day and date_end >= self.current_start_day: + update = True + break + + if update: + self.update(triggered_manually=False) + self.last_revision = revision + self.save() + + def update(self, triggered_manually: bool = True): + """Regenerate the PDF file with the substitutions plan.""" + file_object, result = generate_pdf_from_template( + "chronos/substitutions_print.html", self.get_context_data() + ) + with allow_join_result(): + result.wait() + file_object.refresh_from_db() + if result.status == SUCCESS and file_object.file: + self.current_file.save("current.pdf", file_object.file.file) + self.last_update = timezone.now() + self.last_update_triggered_manually = triggered_manually + self.save() + + def get_current_file(self) -> File: + """Get current PDF file.""" + if not self.current_file: + self.update() + return self.current_file.file + + @property + def filename(self) -> str: + """Get filename (without path) of the PDF file.""" + return f"{self.slug}.pdf" + + @property + def path(self) -> str: + """Get the relative path of the PDF file in the media directory.""" + return f"chronos/plans/{self.filename}" + + @property + def local_path(self) -> str: + """Get the full path under which the PDF file can accessed on the local system.""" + return default_storage.path(self.path) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + if self.current_file: + if default_storage.exists(self.path): + default_storage.delete(self.path) + default_storage.save(self.path, self.current_file.file) + + class Meta: + verbose_name = _("Automatic plan") + verbose_name_plural = _("Automatic plans") + constraints = [models.UniqueConstraint(fields=["site_id", "slug"], name="site_slug")] + + class ChronosGlobalPermissions(GlobalPermissionModel): class Meta: managed = False diff --git a/aleksis/apps/chronos/util/change_tracker.py b/aleksis/apps/chronos/util/change_tracker.py new file mode 100644 index 0000000000000000000000000000000000000000..d4104ec3cd3c8fb34f851dd39a528ff9092794c8 --- /dev/null +++ b/aleksis/apps/chronos/util/change_tracker.py @@ -0,0 +1,38 @@ +from django.contrib.contenttypes.models import ContentType +from django.dispatch import Signal, receiver + +from celery import shared_task +from reversion.models import Revision + + +def _get_substitution_models(): + from aleksis.apps.chronos.models import ( + Event, + ExtraLesson, + LessonSubstitution, + SupervisionSubstitution, + ) + + return [LessonSubstitution, Event, ExtraLesson, SupervisionSubstitution] + + +chronos_revision_created = Signal() +substitutions_changed = Signal() + + +@shared_task +def handle_new_revision(revision_pk: int): + """Handle a new revision in background using Celery.""" + revision = Revision.objects.get(pk=revision_pk) + if revision.version_set.filter(content_type__app_label="chronos").exists(): + chronos_revision_created.send(sender=revision) + + +@receiver(chronos_revision_created) +def handle_substitution_change(sender: Revision, **kwargs): + """Handle the change of a substitution-like object.""" + # Filter versions by substitution-like models + content_types = ContentType.objects.get_for_models(*_get_substitution_models()).values() + versions = sender.version_set.filter(content_type__in=content_types) + if versions: + substitutions_changed.send(sender=sender, versions=versions)