Skip to content
Commits on Source (26)
......@@ -3,8 +3,8 @@ include:
file: /ci/general.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/prepare/lock.yml
# - project: "AlekSIS/official/AlekSIS"
# file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/test.yml
- project: "AlekSIS/official/AlekSIS"
file: /ci/test/lint.yml
- project: "AlekSIS/official/AlekSIS"
......
......@@ -6,6 +6,19 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog`_,
and this project adheres to `Semantic Versioning`_.
`2.3`_ - 2022-03-21
-------------------
Added
~~~~~
* Add support for notifications about current changes to the users' timetables.
Fixed
~~~~~
* *All timetables* showed teachers and rooms from all school terms and not only the current.
`2.2.1`_ - 2022-02-13
---------------------
......
from typing import Any, Optional
import django.apps
from django.db import transaction
from reversion.signals import post_revision_commit
......@@ -33,3 +36,75 @@ 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):
"""Update or create the task for sending notifications."""
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)
# Ensure that the notification task is created after setting up AlekSIS
self._ensure_notification_task()
from datetime import date
from typing import Optional, Union
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from jsonstore import BooleanField
from reversion.models import Revision
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_
......@@ -149,3 +154,16 @@ Person.add_permission(
"view_person_timetable",
_("Can view person timetable"),
)
@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_site"]:
return
for change in sender.changes.values():
if change.deleted:
continue
send_notifications_for_object(change.instance)
......@@ -29,6 +29,7 @@ from calendarweek.django import CalendarWeek, i18n_day_abbr_choices_lazy, i18n_d
from celery.result import allow_join_result
from celery.states import SUCCESS
from colorfield.fields import ColorField
from model_utils import FieldTracker
from reversion.models import Revision, Version
from aleksis.apps.chronos.managers import (
......@@ -427,6 +428,8 @@ class Lesson(ValidityRangeRelatedExtensibleModel, GroupPropertiesMixin, TeacherP
class LessonSubstitution(ExtensibleModel, TeacherPropertiesMixin, WeekRelatedMixin):
objects = LessonSubstitutionManager.from_queryset(LessonSubstitutionQuerySet)()
tracker = FieldTracker()
week = models.IntegerField(verbose_name=_("Week"), default=CalendarWeek.current_week)
year = models.IntegerField(verbose_name=_("Year"), default=get_current_year)
......@@ -466,6 +469,16 @@ class LessonSubstitution(ExtensibleModel, TeacherPropertiesMixin, WeekRelatedMix
week = CalendarWeek(week=self.week, year=self.year)
return week[self.lesson_period.period.weekday]
@property
def time_range(self) -> (timezone.datetime, timezone.datetime):
"""Get the time range of this substitution."""
return timezone.datetime.combine(
self.date, self.lesson_period.period.time_start
), timezone.datetime.combine(self.date, self.lesson_period.period.time_end)
def get_teachers(self):
return self.teachers
def __str__(self):
return f"{self.lesson_period}, {date_format(self.date)}"
......@@ -998,6 +1011,8 @@ class Supervision(ValidityRangeRelatedExtensibleModel, WeekAnnotationMixin):
class SupervisionSubstitution(ExtensibleModel):
objects = SupervisionSubstitutionManager()
tracker = FieldTracker()
date = models.DateField(verbose_name=_("Date"))
supervision = models.ForeignKey(
Supervision,
......@@ -1016,6 +1031,17 @@ class SupervisionSubstitution(ExtensibleModel):
def teachers(self):
return [self.teacher]
@property
def time_range(self) -> (timezone.datetime, timezone.datetime):
"""Get the time range of this supervision substitution."""
return timezone.datetime.combine(
self.date,
self.supervision.break_item.time_start or self.supervision.break_item.time_end,
), timezone.datetime.combine(
self.date,
self.supervision.break_item.time_end or self.supervision.break_item.time_start,
)
def __str__(self):
return f"{self.supervision}, {date_format(self.date)}"
......@@ -1028,6 +1054,8 @@ class SupervisionSubstitution(ExtensibleModel):
class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPropertiesMixin):
label_ = "event"
tracker = FieldTracker()
objects = EventManager.from_queryset(EventQuerySet)()
title = models.CharField(verbose_name=_("Title"), max_length=255, blank=True, null=True)
......@@ -1126,6 +1154,13 @@ class Event(SchoolTermRelatedExtensibleModel, GroupPropertiesMixin, TeacherPrope
"""Get teachers relation."""
return self.teachers
@property
def time_range(self) -> (timezone.datetime, timezone.datetime):
"""Get the time range of this event."""
return timezone.datetime.combine(
self.date_start, self.period_from.time_start
), timezone.datetime.combine(self.date_end, self.period_to.time_end)
class Meta:
# Heads up: Link to period implies uniqueness per site
ordering = ["date_start"]
......@@ -1145,6 +1180,8 @@ class ExtraLesson(
):
label_ = "extra_lesson"
tracker = FieldTracker()
objects = ExtraLessonManager.from_queryset(ExtraLessonQuerySet)()
week = models.IntegerField(verbose_name=_("Week"), default=CalendarWeek.current_week)
......@@ -1195,6 +1232,13 @@ class ExtraLesson(
"""Get subject."""
return self.subject
@property
def time_range(self) -> (timezone.datetime, timezone.datetime):
"""Get the time range of this extra lesson."""
return timezone.datetime.combine(
self.date, self.period.time_start
), timezone.datetime.combine(self.date, self.period.time_end)
class Meta:
# Heads up: Link to period implies uniqueness per site
verbose_name = _("Extra lesson")
......
from datetime import time
from django.utils.translation import gettext_lazy as _
from dynamic_preferences.preferences import Section
from dynamic_preferences.types import BooleanPreference, IntegerPreference
from dynamic_preferences.types import BooleanPreference, IntegerPreference, TimePreference
from aleksis.core.registries import person_preferences_registry, site_preferences_registry
......@@ -67,3 +69,41 @@ class AffectedGroupsUseParentGroups(BooleanPreference):
verbose_name = _(
"Show parent groups in header box in substitution views instead of original groups"
)
@site_preferences_registry.register
class DaysInAdvanceNotifications(IntegerPreference):
section = chronos
name = "days_in_advance_notifications"
default = 1
verbose_name = _("How many days in advance users should be notified about timetable changes?")
@site_preferences_registry.register
class TimeForSendingNotifications(TimePreference):
section = chronos
name = "time_for_sending_notifications"
default = time(17, 00)
verbose_name = _("Time for sending notifications about timetable changes")
required = True
help_text = _(
"This is only used for scheduling notifications "
"which doesn't affect the time period configured above. "
"All other notifications affecting the next days are sent immediately."
)
@site_preferences_registry.register
class SendNotifications(BooleanPreference):
section = chronos
name = "send_notifications_site"
default = True
verbose_name = _("Send notifications for current timetable changes")
@person_preferences_registry.register
class SendNotificationsPerson(BooleanPreference):
section = chronos
name = "send_notifications"
default = True
verbose_name = _("Send notifications for current timetable changes")
from datetime import time, timedelta
from django.contrib.auth import get_user_model
from django.utils import timezone
import pytest
from aleksis.apps.chronos.util.chronos_helpers import get_rooms, get_teachers
from aleksis.core.models import Group, Person, SchoolTerm
pytestmark = pytest.mark.django_db
from aleksis.apps.chronos.models import (
Lesson,
LessonPeriod,
Room,
Subject,
TimePeriod,
ValidityRange,
)
def test_rooms_teachers_only_from_current_school_term():
User = get_user_model()
user = User.objects.create(username="test", is_staff=True, is_superuser=True)
person_user = Person.objects.create(user=user, first_name="Test", last_name="User")
correct_school_term = SchoolTerm.objects.create(
date_start=timezone.now() - timedelta(days=1),
date_end=timezone.now() + timedelta(days=1),
name="Correct school term",
)
wrong_school_term = SchoolTerm.objects.create(
date_start=timezone.now() - timedelta(days=3),
date_end=timezone.now() - timedelta(days=2),
name="Wrong school term",
)
correct_validity = ValidityRange.objects.create(
school_term=correct_school_term,
date_start=correct_school_term.date_start,
date_end=correct_school_term.date_end,
name="Correct validity",
)
wrong_validity = ValidityRange.objects.create(
school_term=wrong_school_term,
date_start=wrong_school_term.date_start,
date_end=wrong_school_term.date_end,
name="Wrong validity",
)
subject = Subject.objects.create(name="Test subject", short_name="TS")
time_period = TimePeriod.objects.create(
weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0)
)
correct_person = Person.objects.create(first_name="Correct", last_name="Person")
wrong_person = Person.objects.create(first_name="Wrong", last_name="Person")
correct_lesson = Lesson.objects.create(validity=correct_validity, subject=subject)
correct_lesson.teachers.add(correct_person)
wrong_lesson = Lesson.objects.create(validity=wrong_validity, subject=subject)
wrong_lesson.teachers.add(wrong_person)
correct_room = Room.objects.create(name="Correct room", short_name="cr")
wrong_room = Room.objects.create(name="Wrong room", short_name="wr")
correct_lesson_period = LessonPeriod.objects.create(
lesson=correct_lesson, period=time_period, room=correct_room
)
wrong_lesson_period = LessonPeriod.objects.create(
lesson=wrong_lesson, period=time_period, room=wrong_room
)
rooms = get_rooms(user)
assert correct_room in rooms
assert wrong_room not in rooms
teachers = get_teachers(user)
assert correct_person in teachers
assert wrong_person not in teachers
from datetime import date, time
from django.db import transaction
from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete
from django.test import TransactionTestCase, override_settings
import pytest
from aleksis.apps.chronos.models import (
Event,
ExtraLesson,
Lesson,
LessonPeriod,
LessonSubstitution,
Room,
Subject,
SupervisionSubstitution,
TimePeriod,
)
from aleksis.apps.chronos.util.change_tracker import TimetableDataChangeTracker
from aleksis.core.models import Group, Person, SchoolTerm
pytestmark = pytest.mark.django_db
@override_settings(CELERY_BROKER_URL="memory://localhost//")
class NotificationTests(TransactionTestCase):
serialized_rollback = True
def setUp(self):
self.school_term = SchoolTerm.objects.create(
date_start=date(2020, 1, 1), date_end=date(2020, 12, 31)
)
self.teacher_a = Person.objects.create(
first_name="Teacher", last_name="A", short_name="A", email="test@example.org"
)
self.teacher_b = Person.objects.create(
first_name="Teacher", last_name="B", short_name="B", email="test@example.org"
)
self.student_a = Person.objects.create(
first_name="Student", last_name="A", email="test@example.org"
)
self.student_b = Person.objects.create(
first_name="Student", last_name="B", email="test@example.org"
)
self.student_c = Person.objects.create(
first_name="Student", last_name="C", email="test@example.org"
)
self.student_d = Person.objects.create(
first_name="Student", last_name="D", email="test@example.org"
)
self.student_e = Person.objects.create(
first_name="Student", last_name="E", email="test@example.org"
)
self.group_a = Group.objects.create(
name="Class 9a", short_name="9a", school_term=self.school_term
)
self.group_a.owners.add(self.teacher_a)
self.group_a.members.add(self.student_a, self.student_b, self.student_c)
self.group_b = Group.objects.create(
name="Class 9b", short_name="9b", school_term=self.school_term
)
self.group_b.owners.add(self.teacher_b)
self.group_b.members.add(self.student_c, self.student_d, self.student_e)
self.time_period_a = TimePeriod.objects.create(
weekday=0, period=1, time_start=time(8, 0), time_end=time(9, 0)
)
self.time_period_b = TimePeriod.objects.create(
weekday=1, period=2, time_start=time(9, 0), time_end=time(10, 0)
)
self.subject_a = Subject.objects.create(name="English", short_name="En")
self.subject_b = Subject.objects.create(name="Deutsch", short_name="De")
self.room_a = Room.objects.create(short_name="004", name="Room 0.04")
self.room_b = Room.objects.create(short_name="005", name="Room 0.05")
self.lesson = Lesson.objects.create(subject=self.subject_a)
self.lesson.groups.set([self.group_a])
self.lesson.teachers.set([self.teacher_a])
self.period_1 = LessonPeriod.objects.create(
period=self.time_period_a, room=self.room_a, lesson=self.lesson
)
self.period_2 = LessonPeriod.objects.create(
period=self.time_period_b, room=self.room_a, lesson=self.lesson
)
def _parse_receivers(self, receivers):
return [str(r[1]) for r in receivers]
def test_signal_registration(self):
for model in [Event, LessonSubstitution, ExtraLesson, SupervisionSubstitution]:
assert "TimetableDataChangeTracker._handle_save" not in "".join(
[str(r) for r in post_save._live_receivers(model)]
)
for model in [Event, LessonSubstitution, ExtraLesson, SupervisionSubstitution]:
assert "TimetableDataChangeTracker._handle_delete" not in "".join(
[str(r) for r in post_delete._live_receivers(model)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(LessonSubstitution.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(Event.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(Event.groups.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(ExtraLesson.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(ExtraLesson.groups.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" not in "".join(
[str(r) for r in m2m_changed._live_receivers(ExtraLesson.groups.through)]
)
with transaction.atomic():
tracker = TimetableDataChangeTracker()
for model in [Event, LessonSubstitution, ExtraLesson, SupervisionSubstitution]:
assert "TimetableDataChangeTracker._handle_save" in "".join(
[str(r) for r in post_save._live_receivers(model)]
)
for model in [Event, LessonSubstitution, ExtraLesson, SupervisionSubstitution]:
assert "TimetableDataChangeTracker._handle_delete" in "".join(
[str(r) for r in pre_delete._live_receivers(model)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" in "".join(
[str(r) for r in m2m_changed._live_receivers(LessonSubstitution.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" in "".join(
[str(r) for r in m2m_changed._live_receivers(Event.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" in "".join(
[str(r) for r in m2m_changed._live_receivers(Event.groups.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" in "".join(
[str(r) for r in m2m_changed._live_receivers(ExtraLesson.teachers.through)]
)
assert "TimetableDataChangeTracker._handle_m2m_changed" in "".join(
[str(r) for r in m2m_changed._live_receivers(ExtraLesson.groups.through)]
)
def test_outside_transaction(self):
with pytest.raises(RuntimeError):
TimetableDataChangeTracker()
def test_create_detection(self):
with transaction.atomic():
tracker = TimetableDataChangeTracker()
assert not tracker.changes
lesson_substitution = LessonSubstitution.objects.create(
week=20, year=2020, lesson_period=self.period_1, cancelled=True
)
assert tracker.changes
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert change.created
assert not change.deleted
assert not change.changed_fields
lesson_substitution.cancelled = False
lesson_substitution.subject = self.subject_b
lesson_substitution.save()
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert change.created
assert not change.deleted
assert change.changed_fields
def test_change_detection(self):
with transaction.atomic():
lesson_substitution = LessonSubstitution.objects.create(
week=20, year=2020, lesson_period=self.period_1, cancelled=True
)
tracker = TimetableDataChangeTracker()
assert not tracker.changes
lesson_substitution.cancelled = False
lesson_substitution.subject = self.subject_b
lesson_substitution.save()
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert not change.created
assert not change.deleted
assert set(change.changed_fields.keys()) == {"cancelled", "subject_id"}
assert change.changed_fields["cancelled"]
assert change.changed_fields["subject_id"] is None
lesson_substitution.teachers.add(self.teacher_a)
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert not change.created
assert not change.deleted
assert set(change.changed_fields.keys()) == {"cancelled", "subject_id", "teachers"}
assert change.changed_fields["teachers"] == []
lesson_substitution.teachers.remove(self.teacher_a)
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert not change.created
assert not change.deleted
assert set(change.changed_fields.keys()) == {"cancelled", "subject_id", "teachers"}
assert change.changed_fields["teachers"] == []
with transaction.atomic():
lesson_substitution.teachers.add(self.teacher_a)
tracker = TimetableDataChangeTracker()
lesson_substitution.teachers.remove(self.teacher_a)
assert len(tracker.changes) == 1
change = tracker.changes[tracker.get_instance_key(lesson_substitution)]
assert change.instance == lesson_substitution
assert not change.created
assert not change.deleted
assert set(change.changed_fields.keys()) == {"teachers"}
assert change.changed_fields["teachers"] == [self.teacher_a]
def test_delete_detected(self):
lesson_substitution = LessonSubstitution.objects.create(
week=20, year=2020, lesson_period=self.period_1, cancelled=True
)
with transaction.atomic():
tracker = TimetableDataChangeTracker()
pk = lesson_substitution.pk
assert not tracker.changes
lesson_substitution.delete()
assert len(tracker.changes) == 1
change = tracker.changes[f"lessonsubstitution_{pk}"]
assert change.instance == lesson_substitution
assert not change.created
assert change.deleted
assert not change.changed_fields
from typing import Any, Optional, Type
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import Signal, receiver
from celery import shared_task
......@@ -16,8 +21,121 @@ def _get_substitution_models():
return [LessonSubstitution, Event, ExtraLesson, SupervisionSubstitution]
class TimetableChange:
"""A change to timetable models."""
def __init__(
self,
instance: Model,
changed_fields: Optional[dict[str, Any]] = None,
created: bool = False,
deleted: bool = False,
):
self.instance = instance
self.changed_fields = changed_fields or {}
self.created = created
self.deleted = deleted
class TimetableDataChangeTracker:
"""Helper class for tracking changes in timetable models by using signals."""
@classmethod
def get_models(cls) -> list[Type[Model]]:
"""Return all models that should be tracked."""
from aleksis.apps.chronos.models import (
Event,
ExtraLesson,
LessonSubstitution,
SupervisionSubstitution,
)
return [LessonSubstitution, Event, ExtraLesson, SupervisionSubstitution]
def __init__(self):
self.changes = {}
self.m2m_fields = {}
if transaction.get_autocommit():
raise RuntimeError("Cannot use change tracker outside of transaction")
for model in self.get_models():
post_save.connect(self._handle_save, sender=model, weak=False)
pre_delete.connect(self._handle_delete, sender=model, weak=False)
# Register signals for all relevant m2m fields
m2m_fields = {
getattr(model, f.name).through: f
for f in model._meta.get_fields()
if f.many_to_many
}
self.m2m_fields.update(m2m_fields)
for through_model, field in m2m_fields.items():
m2m_changed.connect(self._handle_m2m_changed, sender=through_model, weak=False)
transaction.on_commit(self.close)
def get_instance_key(self, instance: Model) -> str:
"""Get unique string key for an instance."""
return f"{instance._meta.model_name}_{instance.id}"
def _add_change(self, change: TimetableChange):
"""Add a change to the list of changes and update, if necessary."""
key = self.get_instance_key(change.instance)
if key not in self.changes or change.deleted or change.created:
self.changes[key] = change
else:
self.changes[key].changed_fields.update(change.changed_fields)
def _handle_save(self, sender: Type[Model], instance: Model, created: bool, **kwargs):
"""Handle the save signal."""
change = TimetableChange(instance, created=created)
if not created:
change.changed_fields = instance.tracker.changed()
self._add_change(change)
def _handle_delete(self, sender: Type[Model], instance: Model, **kwargs):
"""Handle the delete signal."""
change = TimetableChange(instance, deleted=True)
self._add_change(change)
def _handle_m2m_changed(
self,
sender: Type[Model],
instance: Model,
action: str,
model: Type[Model],
pk_set: set,
**kwargs,
):
"""Handle the m2m_changed signal."""
if action in ["pre_add", "pre_remove", "pre_clear"]:
field_name = self.m2m_fields[sender].name
current_value = list(getattr(instance, field_name).all())
if self.get_instance_key(instance) in self.changes:
change = self.changes[self.get_instance_key(instance)]
if field_name in change.changed_fields:
current_value = None
if current_value is not None:
change = TimetableChange(instance, changed_fields={field_name: current_value})
self._add_change(change)
def close(self):
"""Disconnect signals and send change signal."""
for model in self.get_models():
post_save.disconnect(self._handle_save, sender=model)
pre_delete.disconnect(self._handle_delete, sender=model)
for through_model, field in self.m2m_fields.items():
m2m_changed.disconnect(self._handle_m2m_changed, sender=through_model)
timetable_data_changed.send(sender=self, changes=self.changes)
chronos_revision_created = Signal()
substitutions_changed = Signal()
timetable_data_changed = Signal()
@shared_task
......
......@@ -9,7 +9,7 @@ from django.utils import timezone
from guardian.core import ObjectPermissionChecker
from aleksis.core.models import Announcement, Group, Person
from aleksis.core.models import Announcement, Group, Person, SchoolTerm
from aleksis.core.util.core_helpers import get_site_preferences
from aleksis.core.util.predicates import check_global_permission
......@@ -61,8 +61,10 @@ def get_teachers(user: "User"):
"""Get the teachers whose timetables are allowed to be seen by current user."""
checker = ObjectPermissionChecker(user)
school_term = SchoolTerm.current
school_term_q = Q(lessons_as_teacher__validity__school_term=school_term) if school_term else Q()
teachers = (
Person.objects.annotate(lessons_count=Count("lessons_as_teacher"))
Person.objects.annotate(lessons_count=Count("lessons_as_teacher", filter=school_term_q))
.filter(lessons_count__gt=0)
.order_by("short_name", "last_name")
)
......@@ -120,8 +122,13 @@ def get_rooms(user: "User"):
"""Get the rooms whose timetables are allowed to be seen by current user."""
checker = ObjectPermissionChecker(user)
school_term = SchoolTerm.current
school_term_q = (
Q(lesson_periods__lesson__validity__school_term=school_term) if school_term else Q()
)
rooms = (
Room.objects.annotate(lessons_count=Count("lesson_periods"))
Room.objects.annotate(lessons_count=Count("lesson_periods", filter=school_term_q))
.filter(lessons_count__gt=0)
.order_by("short_name", "name")
)
......
This diff is collapsed.
Managing timetable and substitution data
========================================
Currently, CHronos does not provide an interface for
Currently, Chronos does not provide an interface for
interactively managing timetable data.
Instead, data is imported from an external source. The
......
Setup notifications about current changes
=========================================
Users can get notifications about current changes to their personal timetables.
To activate this behavior, the system administrator has to ensure multiple things:
* The data come from a compatible source, for example, AlekSIS-App-Untis.
* The notifications have been activated in the preferences (see below).
* There is at least one notification channel available to your users (cf. :ref:`core-admin-notifications`).
Preferences
-----------
You can customize the way how and when notifications are sent at the configuration page at **Admin → Configuration → Timetables**:
* **Send notifications for current timetable changes:** With this checkbox, the whole feature can be activated or deactivated.
* **How many days in advance users should be notified about timetable changes?** Here the number of days can be configured notifications will be sent
before the actual affected day. A common value is one or two days.
* **Time for sending notifications about timetable changes:** At this time, the notifications for the next days will be sent.
This is only used if the changes are created before the period configured with the above mentioned option. If they affect a day in this period,
the notification will be sent immediately.
......@@ -29,9 +29,9 @@ copyright = "2018-2022 The AlekSIS team"
author = "The AlekSIS Team"
# The short X.Y version
version = "2.2"
version = "2.3"
# The full version, including alpha/beta/rc tags
release = "2.2.1"
release = "2.3"
# -- General configuration ---------------------------------------------------
......