diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index de51e438bf9ff427dff1714d0ad63267ecd7e278..805465432bfbd7af01ac16b15891dc15f2b0ba1e 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -247,7 +247,14 @@ class Availability(models.Model): f'{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}') @classmethod - def with_event_length(cls, event, person=None, room=None, ak=None, ak_category=None): + def with_event_length( + cls, + event: Event, + person: AKOwner | None = None, + room: Room | None = None, + ak: AK | None = None, + ak_category: AKCategory | None = None, + ) -> "Availability": """ Create an availability covering exactly the time between event start and event end. Can e.g., be used to create default availabilities. @@ -268,7 +275,14 @@ class Availability(models.Model): room=room, ak=ak, ak_category=ak_category) @classmethod - def is_event_covered(cls, event, availabilities: List['Availability']) -> bool: + def is_event_covered(cls, event: Event, availabilities: List['Availability']) -> bool: + """Check if list of availibilities cover whole event. + + :param event: event to check. + :param availabilities: availabilities to check. + :return: whether the availabilities cover full event. + :rtype: bool + """ # NOTE: Cannot use `Availability.with_event_length` as its end is the # event end + 1 day full_event = Availability(event=event, start=event.start, end=event.end) diff --git a/AKModel/forms.py b/AKModel/forms.py index f74dc39e5a73ed7d8bb248e3071300c093ebff74..74ca1b6813f0365d1179166da18aeb8a8c59ca4e 100644 --- a/AKModel/forms.py +++ b/AKModel/forms.py @@ -275,6 +275,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): class JSONImportForm(AdminIntermediateForm): + """Form to import an AK schedule from a json file.""" json_data = forms.CharField( required=True, widget=forms.Textarea, diff --git a/AKModel/models.py b/AKModel/models.py index 3a91facb1319eb7b5b2c8d6850143888770615f0..491594273737d06db119c217a305000b86f9e486 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -8,7 +8,6 @@ from django.apps import apps from django.db.models import Count from django.urls import reverse_lazy from django.utils import timezone -from django.utils.datetime_safe import datetime from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from simple_history.models import HistoricalRecords @@ -167,6 +166,24 @@ class Event(models.Model): def _generate_slots_from_block( self, start: datetime, end: datetime, slot_duration: timedelta, slot_index: int = 0 ) -> Iterable[list[int, "Availability"]]: + """Discretize a time range into timeslots. + + Uses a uniform discretization into blocks of length `slot_duration`, + starting at `start`. No incomplete timeslots are generated, i.e. + if (`end` - `start`) is not a whole number multiple of `slot_duration` + then the last incomplete timeslot is dropped. + + :param start: Start of the time range. + :param end: Start of the time range. + :param slot_duration: Duration of a single timeslot in the discretization. + :param slot_index: index of the first timeslot. Defaults to 0. + + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of tuples, each consisisting of the timeslot id + and its availability to indicate its start and duration. + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel from AKModel.availability.models import Availability current_slot_start = start @@ -208,14 +225,32 @@ class Event(models.Model): return slot_index def uniform_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]: + """Uniformly discretize the entire event into a single block of timeslots. + + :param slots_in_an_hour: The percentage of an hour covered by a single slot. + Determines the discretization granularity. + :yield: Block of optimizer timeslots as the discretization result. + :ytype: a single list of tuples, each consisisting of the timeslot id + and its availability to indicate its start and duration. + """ yield from self._generate_slots_from_block( start=self.start, end=self.end, - slot_duration=timedelta(hours=(1.0 / slots_in_an_hour)), + slot_duration=timedelta(hours=1.0 / slots_in_an_hour), ) def default_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]: - slot_duration = timedelta(hours=(1.0 / slots_in_an_hour)) + """Discretize the all default slots into a blocks of timeslots. + + In the discretization each default slot corresponds to one block. + + :param slots_in_an_hour: The percentage of an hour covered by a single slot. + Determines the discretization granularity. + :yield: Block of optimizer timeslots as the discretization result. + :ytype: list of tuples, each consisisting of the timeslot id + and its availability to indicate its start and duration. + """ + slot_duration = timedelta(hours=1.0 / slots_in_an_hour) slot_index = 0 for block_slot in DefaultSlot.objects.filter(event=self).order_by("start", "end"): @@ -228,6 +263,13 @@ class Event(models.Model): ) def schedule_from_json(self, schedule: str) -> None: + """Load AK schedule from a json string. + + :param schedule: A string that can be decoded to json, describing + the AK schedule. The json data is assumed to be constructed + following the output specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + """ schedule = json.loads(schedule) slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"] @@ -602,6 +644,15 @@ class Room(models.Model): return self.title def as_json(self) -> str: + """Return a json string representation of this room object. + + :return: The json string representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: str + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel from AKModel.availability.models import Availability # check if room is available for the whole event @@ -725,6 +776,15 @@ class AKSlot(models.Model): super().save(force_insert, force_update, using, update_fields) def as_json(self) -> str: + """Return a json string representation of the AK object of this slot. + + :return: The json string representation is constructed + following the input specification of the KoMa conference optimizer, cf. + https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format + :rtype: str + """ + # local import to prevent cyclic import + # pylint: disable=import-outside-toplevel from AKModel.availability.models import Availability # check if ak resp. owner is available for the whole event @@ -738,9 +798,9 @@ class AKSlot(models.Model): def _owner_time_constraints(owner: AKOwner): if Availability.is_event_covered(self.event, owner.availabilities.all()): return [] - else: - return [f"availability-person-{owner.pk}"] + return [f"availability-person-{owner.pk}"] + # self.slots_in_an_hour is set in AKJSONExportView data = { "id": str(self.pk), "duration": int(self.duration * self.slots_in_an_hour), diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py index 21a6fd72c8ebb5bd57183cb5ee5e536892c90b08..818f15d9ebe7e6a08e437f88fb9da6844cda179c 100644 --- a/AKModel/views/ak.py +++ b/AKModel/views/ak.py @@ -1,5 +1,4 @@ import json -from datetime import timedelta from typing import List from django.contrib import messages @@ -7,6 +6,7 @@ from django.urls import reverse_lazy from django.utils.translation import gettext_lazy as _ from django.views.generic import ListView, DetailView +from AKModel.availability.models import Availability from AKModel.metaviews.admin import AdminViewMixin, FilterByEventSlugMixin, EventSlugMixin, IntermediateAdminView, \ IntermediateAdminActionView from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK, Room, AKOwner @@ -50,25 +50,44 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): context_object_name = "slots" title = _("AK JSON Export") + def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool: + return any(availability.contains(slot) for availability in availabilities) + + def _test_event_covered(self, availabilities: List[Availability]) -> bool: + return not Availability.is_event_covered(self.event, availabilities) + + def _test_fixed_ak(self, ak_id, slot: Availability, ak_fixed: dict) -> bool: + if not ak_id in ak_fixed: + return False + + fixed_slot = Availability(self.event, start=ak_fixed[ak_id].start, end=ak_fixed[ak_id].end) + return fixed_slot.overlaps(slot, strict=True) + + def _test_add_constraint(self, slot: Availability, availabilities: List[Availability]) -> bool: + return ( + self._test_event_covered(availabilities) + and self._test_slot_contained(slot, availabilities) + ) + + def get_queryset(self): return super().get_queryset().order_by("ak__track") def get_context_data(self, **kwargs): - from AKModel.availability.models import Availability + context = super().get_context_data(**kwargs) + context["participants"] = json.dumps([]) + + rooms = Room.objects.filter(event=self.event) + context["rooms"] = rooms + # TODO: Configure magic number in event SLOTS_IN_AN_HOUR = 1 - rooms = Room.objects.filter(event=self.event) - participants = [] timeslots = { "info": {"duration": (1.0 / SLOTS_IN_AN_HOUR), }, "blocks": [], } - context = super().get_context_data(**kwargs) - context["rooms"] = rooms - context["participants"] = json.dumps(participants) - for slot in context["slots"]: slot.slots_in_an_hour = SLOTS_IN_AN_HOUR @@ -91,22 +110,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists() } - def _test_slot_contained(slot: Availability, availabilities: List[Availability]) -> bool: - return any(availability.contains(slot) for availability in availabilities) - - def _test_event_covered(slot: Availability, availabilities: List[Availability]) -> bool: - return not Availability.is_event_covered(self.event, availabilities) - - def _test_fixed_ak(ak_id, slot: Availability) -> bool: - if not ak_id in ak_fixed: - return False - - fixed_slot = Availability(self.event, start=ak_fixed[ak_id].start, end=ak_fixed[ak_id].end) - return fixed_slot.overlaps(slot, strict=True) - - def _test_add_constraint(slot: Availability, availabilities: List[Availability]) -> bool: - return _test_event_covered(slot, availabilities) and _test_slot_contained(slot, availabilities) - for block in self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR): current_block = [] @@ -119,17 +122,20 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): time_constraints.extend([ f"availability-ak-{ak_id}" for ak_id, availabilities in ak_availabilities.items() - if _test_add_constraint(slot, availabilities) or _test_fixed_ak(ak_id, slot) + if ( + self._test_add_constraint(slot, availabilities) + or self._test_fixed_ak(ak_id, slot, ak_fixed) + ) ]) time_constraints.extend([ f"availability-person-{person_id}" for person_id, availabilities in person_availabilities.items() - if _test_add_constraint(slot, availabilities) + if self._test_add_constraint(slot, availabilities) ]) time_constraints.extend([ f"availability-room-{room_id}" for room_id, availabilities in room_availabilities.items() - if _test_add_constraint(slot, availabilities) + if self._test_add_constraint(slot, availabilities) ]) current_block.append({ diff --git a/AKModel/views/manage.py b/AKModel/views/manage.py index f4564f0903f22d0345d04f6deffa21a67d28585b..ec5076fbd850c71a6f7d1a1d202a46461e43ebbd 100644 --- a/AKModel/views/manage.py +++ b/AKModel/views/manage.py @@ -60,7 +60,7 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting) """ next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None) - return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=[])] + return list(zip_longest(ak_list, next_aks_list, fillvalue=[])) # Get all relevant AKs (wishes separately, and either all AKs or only those who should directly or indirectly # be presented when restriction setting was chosen) @@ -250,6 +250,9 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView): class AKJSONImportView(EventSlugMixin, IntermediateAdminView): + """ + View: Import an AK schedule from a json file that can be pasted into this view. + """ form_class = JSONImportForm title = _("AK JSON Import")