diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 34e5e7cdea7602c11e6c2fd25149706ab36ffda4..da41b23b525199aead22428c0d62898470300f58 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,11 @@ and this project adheres to `Semantic Versioning`_. Unreleased ---------- +Changed +~~~~~~~ + +* Announcements are shown in calendar. + Fixed ~~~~~ diff --git a/aleksis/core/frontend/components/announcements/Announcements.vue b/aleksis/core/frontend/components/announcements/Announcements.vue index 3529992700215d704fd869cd090cc7163c3deff7..ba7d534c850f54a02bdc90ddb86021a15eb587e5 100644 --- a/aleksis/core/frontend/components/announcements/Announcements.vue +++ b/aleksis/core/frontend/components/announcements/Announcements.vue @@ -20,11 +20,11 @@ import PositiveSmallIntegerField from "../generic/forms/PositiveSmallIntegerFiel item-attribute="title" :enable-edit="true" > - <template #validFrom="{ item }"> - {{ $d($parseISODate(item.validFrom), "shortDateTime") }} + <template #datetimeStart="{ item }"> + {{ $d($parseISODate(item.datetimeStart), "shortDateTime") }} </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #validFrom.field="{ attrs, on, item }"> + <template #datetimeStart.field="{ attrs, on, item }"> <div aria-required="true"> <date-time-field dense @@ -32,17 +32,17 @@ import PositiveSmallIntegerField from "../generic/forms/PositiveSmallIntegerFiel v-bind="attrs" v-on="on" required - :max="item.validUntil" + :max="item.datetimeEnd" :rules="$rules().required.build()" /> </div> </template> - <template #validUntil="{ item }"> - {{ $d($parseISODate(item.validUntil), "shortDateTime") }} + <template #datetimeEnd="{ item }"> + {{ $d($parseISODate(item.datetimeEnd), "shortDateTime") }} </template> <!-- eslint-disable-next-line vue/valid-v-slot --> - <template #validUntil.field="{ attrs, on, item }"> + <template #datetimeEnd.field="{ attrs, on, item }"> <div aria-required="true"> <date-time-field dense @@ -50,7 +50,7 @@ import PositiveSmallIntegerField from "../generic/forms/PositiveSmallIntegerFiel v-bind="attrs" v-on="on" required - :min="$parseISODate(item.validFrom).plus({ minutes: 1 }).toISO()" + :min="$parseISODate(item.datetimeStart).plus({ minutes: 1 }).toISO()" :rules="$rules().required.build()" /> </div> @@ -150,12 +150,12 @@ export default { headers: [ { text: this.$t("announcement.valid_from"), - value: "validFrom", + value: "datetimeStart", cols: 6, }, { text: this.$t("announcement.valid_until"), - value: "validUntil", + value: "datetimeEnd", cols: 6, }, { @@ -190,10 +190,10 @@ export default { gqlPatchMutation: patchAnnouncements, gqlDeleteMutation: deleteAnnouncements, defaultItem: { - validFrom: DateTime.now() + datetimeStart: DateTime.now() .startOf("minute") .toISO({ suppressSeconds: true }), - validUntil: DateTime.now() + datetimeEnd: DateTime.now() .startOf("minute") .plus({ hours: 1 }) .toISO({ suppressSeconds: true }), diff --git a/aleksis/core/frontend/components/announcements/announcements.graphql b/aleksis/core/frontend/components/announcements/announcements.graphql index 72ad657079c3680b6b68cd28c60d5846a444ecdd..c43bb4903caf35c2fcfd97ed466c7651e2bb4217 100644 --- a/aleksis/core/frontend/components/announcements/announcements.graphql +++ b/aleksis/core/frontend/components/announcements/announcements.graphql @@ -1,7 +1,7 @@ fragment announcementFields on AnnouncementType { id - validFrom - validUntil + datetimeStart + datetimeEnd title description priority diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py index 7fc89a5a7fa0495213ae072452569a17113095c2..5ae52e070785562eb29197e8df4b2e6e2f934c96 100644 --- a/aleksis/core/managers.py +++ b/aleksis/core/managers.py @@ -1,9 +1,11 @@ -from datetime import date -from typing import TYPE_CHECKING, Union +from datetime import date, datetime +from typing import TYPE_CHECKING, Optional, Union from django.apps import apps -from django.db.models import BooleanField, Case, QuerySet, Value, When +from django.contrib.contenttypes.models import ContentType +from django.db.models import BooleanField, Case, Model, QuerySet, Value, When from django.db.models.manager import Manager +from django.utils import timezone from calendarweek import CalendarWeek from django_cte.cte import CTEQuerySet @@ -225,3 +227,76 @@ class HolidayQuerySet(DateRangeQuerySetMixin, CalendarEventQuerySet): class HolidayManager(CalendarEventManager): queryset_class = HolidayQuerySet + + +class AnnouncementQuerySet(CalendarEventQuerySet): + """Queryset for announcements providing time-based utility functions.""" + + def relevant_for(self, obj: Union[Model, QuerySet]) -> QuerySet: + """Get all relevant announcements. + + Get a QuerySet with all announcements relevant for a certain Model (e.g. a Group) + or a set of models in a QuerySet. + """ + if isinstance(obj, QuerySet): + ct = ContentType.objects.get_for_model(obj.model) + pks = list(obj.values_list("pk", flat=True)) + else: + ct = ContentType.objects.get_for_model(obj) + pks = [obj.pk] + + return self.filter(recipients__content_type=ct, recipients__recipient_id__in=pks) + + def at_time(self, when: Optional[datetime] = None) -> QuerySet: + """Get all announcements at a certain time.""" + when = when or timezone.now() + + # Get announcements by time + announcements = self.filter(datetime_start__lte=when, datetime_end__gte=when) + + return announcements + + def on_date(self, when: Optional[date] = None) -> QuerySet: + """Get all announcements at a certain date.""" + when = when or timezone.now().date() + + # Get announcements by time + announcements = self.filter(datetime_start__date__lte=when, datetime_end__date__gte=when) + + return announcements + + def within_days(self, start: date, stop: date) -> QuerySet: + """Get all announcements valid for a set of days.""" + # Get announcements + announcements = self.filter(datetime_start__date__lte=stop, datetime_end__date__gte=start) + + return announcements + + def for_person(self, person: "Person") -> list: # noqa: F821 + """Get all announcements for one person.""" + # Filter by person + announcements_for_person = [] + for announcement in self: + if person in announcement.recipient_persons: + announcements_for_person.append(announcement) + + return announcements_for_person + + +class AnnouncementManager(CalendarEventManager): + queryset_class = AnnouncementQuerySet + + def relevant_for(self, obj: Union[Model, QuerySet]) -> QuerySet: + return self.get_queryset().relevant_for(obj) + + def at_time(self, when: Optional[datetime] = None) -> QuerySet: + return self.get_queryset().at_time(when) + + def on_date(self, when: Optional[date] = None) -> QuerySet: + return self.get_queryset().on_date(when) + + def within_days(self, start: date, stop: date) -> QuerySet: + return self.get_queryset().within_days(start, stop) + + def for_person(self, person: "Person") -> list: # noqa: F821 + return self.get_queryset().for_person(person) diff --git a/aleksis/core/migrations/0081_migrate_announcements.py b/aleksis/core/migrations/0081_migrate_announcements.py new file mode 100644 index 0000000000000000000000000000000000000000..b75ea4efed5a641ffca569d9b232b18d500cb6ef --- /dev/null +++ b/aleksis/core/migrations/0081_migrate_announcements.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.7 on 2025-04-03 11:42 + +import aleksis.core.mixins +import django.db.models.deletion +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import migrations, models + + +def migrate_announcements(apps, schema_editor): + CalendarEvent = apps.get_model("core", "CalendarEvent") + Announcement = apps.get_model("core", "Announcement") + announcement_ctype = ContentType.objects.get_for_model(Announcement) + + db_alias = schema_editor.connection.alias + + for announcement in Announcement.objects.all(): + event = CalendarEvent.objects.create( + datetime_start=announcement.valid_from, + datetime_end=announcement.valid_until, + polymorphic_ctype_id = announcement_ctype.pk, + extended_data=announcement.extended_data, + managed_by_app_label=announcement.managed_by_app_label, + ) + + announcement.calendarevent_ptr_id = event.pk + announcement.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0080_remove_guardians'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='announcement', + name='calendarevent_ptr', + field=models.OneToOneField(auto_created=True, null=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, serialize=False, to='core.calendarevent'), + ), + migrations.RunPython(migrate_announcements), + ] diff --git a/aleksis/core/migrations/0082_announcement_remove_fields.py b/aleksis/core/migrations/0082_announcement_remove_fields.py new file mode 100644 index 0000000000000000000000000000000000000000..55cab8f7bfb2979f25192c9c59639ec44e928081 --- /dev/null +++ b/aleksis/core/migrations/0082_announcement_remove_fields.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.7 on 2025-04-03 11:42 + +import aleksis.core.mixins +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0081_migrate_announcements'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='announcement', + name='id', + ), + migrations.AlterField( + model_name='announcement', + name='calendarevent_ptr', + field=models.OneToOneField(auto_created=True, null=False, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.calendarevent'), + ), + migrations.RemoveField( + model_name='announcement', + name='extended_data', + ), + migrations.RemoveField( + model_name='announcement', + name='managed_by_app_label', + ), + migrations.RemoveField( + model_name='announcement', + name='valid_from', + ), + migrations.RemoveField( + model_name='announcement', + name='valid_until', + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index bb6e29a59e49b21f3477b38acb3749143f99c30b..8057f697ac6ec7aa77fdb19df68e846b5e3e4add 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -65,6 +65,7 @@ from aleksis.core.data_checks import ( from .managers import ( AlekSISBaseManagerWithoutMigrations, + AnnouncementManager, CalendarEventManager, CalendarEventMixinManager, GroupManager, @@ -87,7 +88,7 @@ from .mixins import ( SchoolTermRelatedExtensibleModel, ) from .tasks import send_notification -from .util.core_helpers import generate_random_code, get_site_preferences, now_tomorrow +from .util.core_helpers import generate_random_code, get_site_preferences from .util.email import send_email from .util.model_helpers import ICONS @@ -960,151 +961,6 @@ class Notification(ExtensibleModel, TimeStampedModel): ] -class AnnouncementQuerySet(models.QuerySet): - """Queryset for announcements providing time-based utility functions.""" - - def relevant_for(self, obj: Union[models.Model, models.QuerySet]) -> models.QuerySet: - """Get all relevant announcements. - - Get a QuerySet with all announcements relevant for a certain Model (e.g. a Group) - or a set of models in a QuerySet. - """ - if isinstance(obj, models.QuerySet): - ct = ContentType.objects.get_for_model(obj.model) - pks = list(obj.values_list("pk", flat=True)) - else: - ct = ContentType.objects.get_for_model(obj) - pks = [obj.pk] - - return self.filter(recipients__content_type=ct, recipients__recipient_id__in=pks) - - def at_time(self, when: Optional[datetime] = None) -> models.QuerySet: - """Get all announcements at a certain time.""" - when = when or timezone.now() - - # Get announcements by time - announcements = self.filter(valid_from__lte=when, valid_until__gte=when) - - return announcements - - def on_date(self, when: Optional[date] = None) -> models.QuerySet: - """Get all announcements at a certain date.""" - when = when or timezone.now().date() - - # Get announcements by time - announcements = self.filter(valid_from__date__lte=when, valid_until__date__gte=when) - - return announcements - - def within_days(self, start: date, stop: date) -> models.QuerySet: - """Get all announcements valid for a set of days.""" - # Get announcements - announcements = self.filter(valid_from__date__lte=stop, valid_until__date__gte=start) - - return announcements - - def for_person(self, person: Person) -> list: - """Get all announcements for one person.""" - # Filter by person - announcements_for_person = [] - for announcement in self: - if person in announcement.recipient_persons: - announcements_for_person.append(announcement) - - return announcements_for_person - - -class Announcement(ExtensibleModel): - """Announcement model. - - Persistent announcement to display to groups or persons in various places during a - specific time range. - """ - - objects = models.Manager.from_queryset(AnnouncementQuerySet)() - - title = models.CharField(max_length=150, verbose_name=_("Title")) - description = models.TextField(max_length=500, verbose_name=_("Description"), blank=True) - link = models.URLField(blank=True, verbose_name=_("Link to detailed view")) - priority = models.PositiveSmallIntegerField(verbose_name=_("Priority"), blank=True, null=True) - - valid_from = models.DateTimeField( - verbose_name=_("Date and time from when to show"), default=timezone.now - ) - valid_until = models.DateTimeField( - verbose_name=_("Date and time until when to show"), - default=now_tomorrow, - ) - - @property - def recipient_persons(self) -> Sequence[Person]: - """Return a list of Persons this announcement is relevant for.""" - persons = [] - for recipient in self.recipients.all(): - persons += recipient.persons - return persons - - def get_recipients_for_model(self, obj: Union[models.Model]) -> Sequence[models.Model]: - """Get all recipients. - - Get all recipients for this announcement - with a special content type (provided through model) - """ - ct = ContentType.objects.get_for_model(obj) - return [r.recipient for r in self.recipients.filter(content_type=ct)] - - def __str__(self): - return self.title - - class Meta: - verbose_name = _("Announcement") - verbose_name_plural = _("Announcements") - - -class AnnouncementRecipient(ExtensibleModel): - """Announcement recipient model. - - Generalisation of a recipient for an announcement, used to wrap arbitrary - objects that can receive announcements. - - Contract: Objects to serve as recipient have a property announcement_recipients - returning a flat list of Person objects. - """ - - announcement = models.ForeignKey( - Announcement, on_delete=models.CASCADE, related_name="recipients" - ) - - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) - recipient_id = models.PositiveIntegerField() - recipient = GenericForeignKey("content_type", "recipient_id") - - @property - def persons(self) -> Sequence[Person]: - """Return a list of Persons selected by this recipient object. - - If the recipient is a Person, return that object. If not, it returns the list - from the announcement_recipients field on the target model. - """ - if isinstance(self.recipient, Person): - return [self.recipient] - else: - return getattr(self.recipient, "announcement_recipients", []) - - def __str__(self): - if hasattr(self.recipient, "short_name") and self.recipient.short_name: - return self.recipient.short_name - elif hasattr(self.recipient, "name") and self.recipient.name: - return self.recipient.name - elif hasattr(self.recipient, "full_name") and self.recipient.full_name: - return self.recipient.full_name - return str(self.recipient) - - class Meta: - verbose_name = _("Announcement recipient") - verbose_name_plural = _("Announcement recipients") - - class DashboardWidget(RegistryObject, PolymorphicModel, PureDjangoModel): """Base class for dashboard widgets on the index page.""" @@ -1988,6 +1844,129 @@ class CalendarEvent( ordering = ["datetime_start", "date_start", "datetime_end", "date_end"] +class Announcement(CalendarEvent): + """Announcement model. + + Persistent announcement to display to groups or persons in various places during a + specific time range. + """ + + _class_name = "announcement" + dav_verbose_name = "Announcements" + + objects = AnnouncementManager() + + title = models.CharField(max_length=150, verbose_name=_("Title")) + description = models.TextField(max_length=500, verbose_name=_("Description"), blank=True) + link = models.URLField(blank=True, verbose_name=_("Link to detailed view")) + priority = models.PositiveSmallIntegerField(verbose_name=_("Priority"), blank=True, null=True) + + @property + def valid_from(self): + return self.datetime_start + + @property + def valid_until(self): + return self.datetime_end + + @valid_from.setter + def valid_from(self, value): + self.datetime_start = value + + @valid_until.setter + def valid_until(self, value): + self.datetime_end = value + + @classmethod + def value_title( + cls, reference_object: "Announcement", request: HttpRequest | None = None + ) -> str: + """Return the title of the announcement.""" + return reference_object.title + + @classmethod + def value_description( + cls, reference_object: "Announcement", request: HttpRequest | None = None + ) -> str: + """Return the description of the announcement.""" + return reference_object.description + + @classmethod + def value_link( + cls, reference_object: "Announcement", request: HttpRequest | None = None + ) -> str: + """Return the link of the announcement.""" + return reference_object.link + + @property + def recipient_persons(self) -> Sequence[Person]: + """Return a list of Persons this announcement is relevant for.""" + persons = [] + for recipient in self.recipients.all(): + persons += recipient.persons + return persons + + def get_recipients_for_model(self, obj: Union[models.Model]) -> Sequence[models.Model]: + """Get all recipients. + + Get all recipients for this announcement + with a special content type (provided through model) + """ + ct = ContentType.objects.get_for_model(obj) + return [r.recipient for r in self.recipients.filter(content_type=ct)] + + def __str__(self): + return self.title + + class Meta: + verbose_name = _("Announcement") + verbose_name_plural = _("Announcements") + + +class AnnouncementRecipient(ExtensibleModel): + """Announcement recipient model. + + Generalisation of a recipient for an announcement, used to wrap arbitrary + objects that can receive announcements. + + Contract: Objects to serve as recipient have a property announcement_recipients + returning a flat list of Person objects. + """ + + announcement = models.ForeignKey( + Announcement, on_delete=models.CASCADE, related_name="recipients" + ) + + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + recipient_id = models.PositiveIntegerField() + recipient = GenericForeignKey("content_type", "recipient_id") + + @property + def persons(self) -> Sequence[Person]: + """Return a list of Persons selected by this recipient object. + + If the recipient is a Person, return that object. If not, it returns the list + from the announcement_recipients field on the target model. + """ + if isinstance(self.recipient, Person): + return [self.recipient] + else: + return getattr(self.recipient, "announcement_recipients", []) + + def __str__(self): + if hasattr(self.recipient, "short_name") and self.recipient.short_name: + return self.recipient.short_name + elif hasattr(self.recipient, "name") and self.recipient.name: + return self.recipient.name + elif hasattr(self.recipient, "full_name") and self.recipient.full_name: + return self.recipient.full_name + return str(self.recipient) + + class Meta: + verbose_name = _("Announcement recipient") + verbose_name_plural = _("Announcement recipients") + + class FreeBusy(CalendarEvent): _class_name = "" diff --git a/aleksis/core/schema/announcement.py b/aleksis/core/schema/announcement.py index 06820afcdc52d0475fff50fd7670fe64665338ca..af52ab7df3768a756040fd4c6dc17c65183dea04 100644 --- a/aleksis/core/schema/announcement.py +++ b/aleksis/core/schema/announcement.py @@ -23,8 +23,8 @@ class AnnouncementType(PermissionsTypeMixin, DjangoObjectType): model = Announcement fields = ( "id", - "valid_from", - "valid_until", + "datetime_start", + "datetime_end", "title", "description", "priority", @@ -45,8 +45,8 @@ class AnnouncementBatchCreateMutation(BaseBatchCreateMutation): model = Announcement permissions = ("core.create_announcement_rule",) only_fields = ( - "valid_from", - "valid_until", + "datetime_start", + "datetime_end", "title", "description", "priority", @@ -85,8 +85,8 @@ class AnnouncementBatchPatchMutation(BaseBatchPatchMutation): permissions = ("core.edit_announcement_rule",) only_fields = ( "id", - "valid_from", - "valid_until", + "datetime_start", + "datetime_end", "title", "description", "priority", diff --git a/aleksis/core/templates/core/partials/announcements.html b/aleksis/core/templates/core/partials/announcements.html index e4aca139435db80aab86936d614f3a165e5183b6..c88055fb16366e961cdb2131d2a9e639eb1399d9 100644 --- a/aleksis/core/templates/core/partials/announcements.html +++ b/aleksis/core/templates/core/partials/announcements.html @@ -4,12 +4,12 @@ <figure class="alert primary"> {% if show_interval %} <em class="right hide-on-small-and-down"> - {% if announcement.valid_from.date == announcement.valid_until.date %} - {% blocktrans with from=announcement.valid_from|naturalday %} + {% if announcement.datetime_start.date == announcement.datetime_end.date %} + {% blocktrans with from=announcement.datetime_start|naturalday %} Valid for {{ from }} {% endblocktrans %} {% else %} - {% blocktrans with from=announcement.valid_from|naturalday until=announcement.valid_until|naturalday %} + {% blocktrans with from=announcement.datetime_start|naturalday until=announcement.datetime_end|naturalday %} Valid from {{ from }} until {{ until }} {% endblocktrans %} {% endif %} @@ -31,12 +31,12 @@ {% if show_interval %} <em class="hide-on-med-and-up"> - {% if announcement.valid_from.date == announcement.valid_until.date %} - {% blocktrans with from=announcement.valid_from|naturalday %} + {% if announcement.datetime_start.date == announcement.datetime_end.date %} + {% blocktrans with from=announcement.datetime_start|naturalday %} Valid for {{ from }} {% endblocktrans %} {% else %} - {% blocktrans with from=announcement.valid_from|naturalday until=announcement.valid_until|naturalday %} + {% blocktrans with from=announcement.datetime_start|naturalday until=announcement.datetime_end|naturalday %} Valid for {{ from }} – {{ until }} {% endblocktrans %} {% endif %}