diff --git a/aleksis/apps/chronos/apps.py b/aleksis/apps/chronos/apps.py index 01ad4f24878b9dffe4942b95fdefacf36561d134..6570299bb628b18ea70b24d19bd6fd4985f4901d 100644 --- a/aleksis/apps/chronos/apps.py +++ b/aleksis/apps/chronos/apps.py @@ -1,3 +1,6 @@ +from typing import Any, Optional + +import django.apps from django.db import transaction from reversion.signals import post_revision_commit @@ -33,3 +36,74 @@ class ChronosConfig(AppConfig): transaction.on_commit(lambda: handle_new_revision.delay(revision.pk)) post_revision_commit.connect(_handle_post_revision_commit, weak=False) + + def _ensure_notification_task(self): + from django.conf import settings # noqa + + from celery import schedules + from django_celery_beat.models import CrontabSchedule, PeriodicTask + + from aleksis.core.util.core_helpers import get_site_preferences + + time_for_sending = get_site_preferences()["chronos__time_for_sending_notifications"] + active = get_site_preferences()["chronos__send_notifications_site"] + + if active: + schedule = schedules.crontab( + minute=str(time_for_sending.minute), hour=str(time_for_sending.hour) + ) + schedule = CrontabSchedule.from_schedule(schedule) + schedule.timezone = settings.TIME_ZONE + schedule.save() + + possible_periodic_tasks = PeriodicTask.objects.filter( + task="chronos_send_notifications_for_next_day" + ) + + if not active: + possible_periodic_tasks.delete() + + elif possible_periodic_tasks.exists(): + task = possible_periodic_tasks[0] + for d_task in possible_periodic_tasks: + if d_task != task: + d_task.delete() + + if task.crontab != schedule: + task.interval, task.solar, task.clocked = None, None, None + task.crontab = schedule + task.save() + + elif active: + PeriodicTask.objects.get_or_create( + task="chronos_send_notifications_for_next_day", + crontab=schedule, + defaults=dict(name="Send notifications for next day (automatic schedule)"), + ) + + def preference_updated( + self, + sender: Any, + section: Optional[str] = None, + name: Optional[str] = None, + old_value: Optional[Any] = None, + new_value: Optional[Any] = None, + **kwargs, + ) -> None: + if section == "chronos" and name in ( + "send_notifications_site", + "time_for_sending_notifications", + ): + self._ensure_notification_task() + + def post_migrate( + self, + app_config: django.apps.AppConfig, + verbosity: int, + interactive: bool, + using: str, + **kwargs, + ) -> None: + super().post_migrate(app_config, verbosity, interactive, using, **kwargs) + + self._ensure_notification_task() diff --git a/aleksis/apps/chronos/model_extensions.py b/aleksis/apps/chronos/model_extensions.py index b324c83f67c6e5a24e25feca17ed31b24690dbc9..096b43e22c2c8094d547bbec57f70619cecb99e6 100644 --- a/aleksis/apps/chronos/model_extensions.py +++ b/aleksis/apps/chronos/model_extensions.py @@ -1,31 +1,22 @@ import zoneinfo from datetime import date, timedelta from typing import Optional, Union -from urllib.parse import urljoin from django.conf import settings from django.dispatch import receiver -from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _ -from django.utils.translation import ngettext from jsonstore import BooleanField from reversion.models import Revision -from aleksis.apps.chronos.models import ( - Event, - ExtraLesson, - LessonSubstitution, - SupervisionSubstitution, -) -from aleksis.core.models import Announcement, Group, Notification, Person +from aleksis.core.models import Announcement, Group, Person from aleksis.core.util.core_helpers import get_site_preferences from .managers import TimetableType from .models import Lesson, LessonPeriod, Subject from .util.change_tracker import timetable_data_changed +from .util.notifications import send_notifications_for_object @Person.property_ @@ -171,7 +162,7 @@ Person.add_permission( @receiver(timetable_data_changed) def send_notifications(sender: Revision, **kwargs): """Send notifications to users about the changes.""" - if not get_site_preferences()["chronos__send_notifications"]: + if not get_site_preferences()["chronos__send_notifications_site"]: return for change in sender.changes.values(): @@ -193,182 +184,4 @@ def send_notifications(sender: Revision, **kwargs): # Skip this because it's not in the current range for notifications continue - recipients = [] - if isinstance(change.instance, LessonSubstitution): - recipients += change.instance.lesson_period.lesson.teachers.all() - recipients += change.instance.teachers.all() - recipients += Person.objects.filter( - member_of__in=change.instance.lesson_period.lesson.groups.all() - ) - elif isinstance(change.instance, Event): - recipients += change.instance.teachers.all() - recipients += Person.objects.filter(member_of__in=change.instance.groups.all()) - elif isinstance(change.instance, ExtraLesson): - recipients += change.instance.teachers.all() - recipients += Person.objects.filter(member_of__in=change.instance.groups.all()) - elif isinstance(change.instance, SupervisionSubstitution): - recipients.append(change.instance.teacher) - recipients.append(change.instance.supervision.teacher) - print(change.instance, recipients) - - description = "" - if isinstance(change.instance, LessonSubstitution): - # Date, lesson, subject - subject = change.instance.lesson_period.lesson.subject - day = change.instance.date - period = change.instance.lesson_period.period - - if change.instance.cancelled: - description += ( - _( - "The {subject} lesson in the {period}. period on {day} has been cancelled." - ).format(subject=subject.name, period=period.period, day=date_format(day)) - + " " - ) - else: - description += ( - _( - "The {subject} lesson in the {period}. period " - "on {day} has some current changes." - ).format(subject=subject.name, period=period.period, day=date_format(day)) - + " " - ) - - if change.instance.teachers.all(): - description += ( - ngettext( - "The teacher {old} is substituted by {new}.", - "The teachers {old} are substituted by {new}.", - change.instance.teachers.count(), - ).format( - old=change.instance.lesson_period.lesson.teacher_names, - new=change.instance.teacher_names, - ) - + " " - ) - - if change.instance.subject: - description += ( - _("The subject is changed to {subject}.").format( - subject=change.instance.subject.name - ) - + " " - ) - - if change.instance.room: - description += ( - _("The lesson is moved from {old} to {new}.").format( - old=change.instance.lesson_period.room.name, - new=change.instance.room.name, - ) - + " " - ) - - if change.instance.comment: - description += ( - _("There is an additional comment: {comment}.").format( - comment=change.instance.comment - ) - + " " - ) - - elif isinstance(change.instance, Event): - if change.instance.date_start != change.instance.date_end: - description += ( - _( - "There is an event that starts on {date_start}, {period_from}. period " - "and ends on {date_end}, {period_to}. period:" - ).format( - date_start=date_format(change.instance.date_start), - date_end=date_format(change.instance.date_end), - period_from=change.instance.period_from.period, - period_to=change.instance.period_to.period, - ) - + "\n" - ) - else: - description += ( - _( - "There is an event on {date} from the " - "{period_from}. period to the {period_to}. period:" - ).format( - date=date_format(change.instance.date_start), - period_from=change.instance.period_from.period, - period_to=change.instance.period_to.period, - ) - + "\n" - ) - - if change.instance.groups.all(): - description += ( - _("Groups: {groups}").format(groups=change.instance.group_names) + "\n" - ) - if change.instance.teachers.all(): - description += ( - _("Teachers: {teachers}").format(teachers=change.instance.teacher_names) + "\n" - ) - if change.instance.rooms.all(): - description += ( - _("Rooms: {rooms}").format( - rooms=", ".join([room.name for room in change.instance.rooms.all()]) - ) - + "\n" - ) - elif isinstance(change.instance, ExtraLesson): - description += ( - _("There is an extra lesson on {date} in the {period}. period:").format( - date=date_format(change.instance.date), - period=change.instance.period.period, - ) - + "\n" - ) - - if change.instance.groups.all(): - description += ( - _("Groups: {groups}").format(groups=change.instance.group_names) + "\n" - ) - if change.instance.room: - description += ( - _("Subject: {subject}").format(subject=change.instance.subject.name) + "\n" - ) - if change.instance.teachers.all(): - description += ( - _("Teachers: {teachers}").format(teachers=change.instance.teacher_names) + "\n" - ) - if change.instance.room: - description += _("Room: {room}").format(room=change.instance.room.name) + "\n" - if change.instance.comment: - description += ( - _("Comment: {comment}.").format(comment=change.instance.comment) + "\n" - ) - elif isinstance(change.instance, SupervisionSubstitution): - description += _( - "The supervision of {old} on {date} between the {period_from}. period " - "and the {period_to}. period in the area {area} is substituted by {new}." - ).format( - old=change.instance.supervision.teacher.full_name, - date=date_format(change.instance.date), - period_from=change.instance.supervision.break_item.after_period_number, - period_to=change.instance.supervision.break_item.before_period_number, - area=change.instance.supervision.area.name, - new=change.instance.teacher.full_name, - ) - - url = urljoin( - settings.BASE_URL, - reverse( - "my_timetable_by_date", - args=[dt_start.date().year, dt_start.date().month, dt_start.date().day], - ), - ) - - for recipient in recipients: - if recipient.preferences["chronos__send_notifications"]: - n = Notification( - recipient=recipient, - sender=_("Timetable"), - title=_("There are current changes to your timetable."), - description=description, - link=url, - ) - n.save() + send_notifications_for_object(change.instance) diff --git a/aleksis/apps/chronos/preferences.py b/aleksis/apps/chronos/preferences.py index fff08ee1c17c883b96fee9d987ff854934b4d2c3..6391b696b3852d7b281a94366e04b5a3bc3fd27e 100644 --- a/aleksis/apps/chronos/preferences.py +++ b/aleksis/apps/chronos/preferences.py @@ -83,7 +83,7 @@ class TimeForSendingNotifications(TimePreference): @site_preferences_registry.register class SendNotifications(BooleanPreference): section = chronos - name = "send_notifications" + name = "send_notifications_site" default = True verbose_name = _("Send notifications for current timetable changes") diff --git a/aleksis/apps/chronos/tasks.py b/aleksis/apps/chronos/tasks.py new file mode 100644 index 0000000000000000000000000000000000000000..90f3ed3eacdaccab2ebd7008d8976e4d49b7798b --- /dev/null +++ b/aleksis/apps/chronos/tasks.py @@ -0,0 +1,25 @@ +from django.utils import timezone + +from aleksis.apps.chronos.models import ( + Event, + ExtraLesson, + LessonSubstitution, + SupervisionSubstitution, +) +from aleksis.apps.chronos.util.notifications import send_notifications_for_object +from aleksis.core.celery import app + + +@app.task(name="chronos_send_notifications_for_next_day") +def send_notifications_for_next_day(): + """Send notifications for next day.""" + next_day = timezone.now().date() + timezone.timedelta(days=1) + + relevant_objects = [] + relevant_objects += LessonSubstitution.objects.on_day(next_day) + relevant_objects += ExtraLesson.objects.on_day(next_day) + relevant_objects += Event.objects.on_day(next_day) + relevant_objects += SupervisionSubstitution.objects.filter(date=next_day) + + for instance in relevant_objects: + send_notifications_for_object(instance) diff --git a/aleksis/apps/chronos/util/notifications.py b/aleksis/apps/chronos/util/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..9cfaea28f2b779e61082fd04fd0a377a264dab92 --- /dev/null +++ b/aleksis/apps/chronos/util/notifications.py @@ -0,0 +1,182 @@ +from typing import Union +from urllib.parse import urljoin + +from django.conf import settings +from django.urls import reverse +from django.utils.formats import date_format +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import ngettext + +from aleksis.core.models import Notification, Person + +from ..models import Event, ExtraLesson, LessonSubstitution, SupervisionSubstitution + + +def send_notifications_for_object( + instance: Union[ExtraLesson, LessonSubstitution, Event, SupervisionSubstitution] +): + """Send notifications for a change object.""" + recipients = [] + if isinstance(instance, LessonSubstitution): + recipients += instance.lesson_period.lesson.teachers.all() + recipients += instance.teachers.all() + recipients += Person.objects.filter( + member_of__in=instance.lesson_period.lesson.groups.all() + ) + elif isinstance(instance, Event): + recipients += instance.teachers.all() + recipients += Person.objects.filter(member_of__in=instance.groups.all()) + elif isinstance(instance, ExtraLesson): + recipients += instance.teachers.all() + recipients += Person.objects.filter(member_of__in=instance.groups.all()) + elif isinstance(instance, SupervisionSubstitution): + recipients.append(instance.teacher) + recipients.append(instance.supervision.teacher) + + description = "" + if isinstance(instance, LessonSubstitution): + # Date, lesson, subject + subject = instance.lesson_period.lesson.subject + day = instance.date + period = instance.lesson_period.period + + if instance.cancelled: + description += ( + _( + "The {subject} lesson in the {period}. period on {day} has been cancelled." + ).format(subject=subject.name, period=period.period, day=date_format(day)) + + " " + ) + else: + description += ( + _( + "The {subject} lesson in the {period}. period " + "on {day} has some current changes." + ).format(subject=subject.name, period=period.period, day=date_format(day)) + + " " + ) + + if instance.teachers.all(): + description += ( + ngettext( + "The teacher {old} is substituted by {new}.", + "The teachers {old} are substituted by {new}.", + instance.teachers.count(), + ).format( + old=instance.lesson_period.lesson.teacher_names, + new=instance.teacher_names, + ) + + " " + ) + + if instance.subject: + description += ( + _("The subject is changed to {subject}.").format(subject=instance.subject.name) + + " " + ) + + if instance.room: + description += ( + _("The lesson is moved from {old} to {new}.").format( + old=instance.lesson_period.room.name, + new=instance.room.name, + ) + + " " + ) + + if instance.comment: + description += ( + _("There is an additional comment: {comment}.").format(comment=instance.comment) + + " " + ) + + elif isinstance(instance, Event): + if instance.date_start != instance.date_end: + description += ( + _( + "There is an event that starts on {date_start}, {period_from}. period " + "and ends on {date_end}, {period_to}. period:" + ).format( + date_start=date_format(instance.date_start), + date_end=date_format(instance.date_end), + period_from=instance.period_from.period, + period_to=instance.period_to.period, + ) + + "\n" + ) + else: + description += ( + _( + "There is an event on {date} from the " + "{period_from}. period to the {period_to}. period:" + ).format( + date=date_format(instance.date_start), + period_from=instance.period_from.period, + period_to=instance.period_to.period, + ) + + "\n" + ) + + if instance.groups.all(): + description += _("Groups: {groups}").format(groups=instance.group_names) + "\n" + if instance.teachers.all(): + description += _("Teachers: {teachers}").format(teachers=instance.teacher_names) + "\n" + if instance.rooms.all(): + description += ( + _("Rooms: {rooms}").format( + rooms=", ".join([room.name for room in instance.rooms.all()]) + ) + + "\n" + ) + elif isinstance(instance, ExtraLesson): + description += ( + _("There is an extra lesson on {date} in the {period}. period:").format( + date=date_format(instance.date), + period=instance.period.period, + ) + + "\n" + ) + + if instance.groups.all(): + description += _("Groups: {groups}").format(groups=instance.group_names) + "\n" + if instance.room: + description += _("Subject: {subject}").format(subject=instance.subject.name) + "\n" + if instance.teachers.all(): + description += _("Teachers: {teachers}").format(teachers=instance.teacher_names) + "\n" + if instance.room: + description += _("Room: {room}").format(room=instance.room.name) + "\n" + if instance.comment: + description += _("Comment: {comment}.").format(comment=instance.comment) + "\n" + elif isinstance(instance, SupervisionSubstitution): + description += _( + "The supervision of {old} on {date} between the {period_from}. period " + "and the {period_to}. period in the area {area} is substituted by {new}." + ).format( + old=instance.supervision.teacher.full_name, + date=date_format(instance.date), + period_from=instance.supervision.break_item.after_period_number, + period_to=instance.supervision.break_item.before_period_number, + area=instance.supervision.area.name, + new=instance.teacher.full_name, + ) + + day = instance.date if hasattr(instance, "date") else instance.date_start + + url = urljoin( + settings.BASE_URL, + reverse( + "my_timetable_by_date", + args=[day.year, day.month, day.day], + ), + ) + + for recipient in recipients: + if recipient.preferences["chronos__send_notifications"]: + n = Notification( + recipient=recipient, + sender=_("Timetable"), + title=_("There are current changes to your timetable."), + description=description, + link=url, + ) + n.save()