diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index 35814ee06d2bf49fe9416710c71cbebbb4fc7bb4..27a6c2287fd804dbf0f6c9c3d52b725f4afeb18c 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -293,7 +293,7 @@ class Availability(models.Model): # event end + 1 day full_event = Availability(event=event, start=event.start, end=event.end) avail_union = Availability.union(availabilities) - return not avail_union or avail_union[0].contains(full_event) + return any(avail.contains(full_event) for avail in avail_union) class Meta: verbose_name = _('Availability') diff --git a/AKModel/forms.py b/AKModel/forms.py index 74ca1b6813f0365d1179166da18aeb8a8c59ca4e..bf77085c6c21a75fafc2711c2cc58c94a8953d46 100644 --- a/AKModel/forms.py +++ b/AKModel/forms.py @@ -274,7 +274,7 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event) -class JSONImportForm(AdminIntermediateForm): +class JSONScheduleImportForm(AdminIntermediateForm): """Form to import an AK schedule from a json file.""" json_data = forms.CharField( required=True, diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index 4cc752ae7d4ac4da883547ad18a5fbe866d816d7..4fcbd8a8b0d5c9e5dbaab566dd88652847e2058c 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -1262,8 +1262,8 @@ msgstr "" "{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht" #: AKModel/views/manage.py:257 -msgid "AK JSON Import" -msgstr "AK-JSON-Import" +msgid "AK Schedule JSON Import" +msgstr "AK-Plan JSON-Import" #: AKModel/views/room.py:37 #, python-format diff --git a/AKModel/models.py b/AKModel/models.py index 2d58a45651b419899932171a0fdae2a394e85d6b..d30912d3046b8ce4a874e775398cf7b6afbace81 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -2,7 +2,7 @@ import itertools import json from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Iterable +from typing import Iterable, Generator from django.db import models from django.apps import apps @@ -17,11 +17,16 @@ from timezone_field import TimeZoneField @dataclass class OptimizerTimeslot: - """Class describing a timeslot. Used to interface with an optimizer.""" + """Class describing a discrete timeslot. Used to interface with an optimizer.""" avail: "Availability" + """The availability object corresponding to this timeslot.""" + idx: int + """The unique index of this optimizer timeslot.""" + constraints: set[str] + """The set of time constraints fulfilled by this object.""" def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot": """Merge with other OptimizerTimeslot. @@ -33,7 +38,6 @@ class OptimizerTimeslot: """ avail = self.avail.merge_with(other.avail) constraints = self.constraints.union(other.constraints) - # we simply use the index of result[-1] return OptimizerTimeslot( avail=avail, idx=self.idx, constraints=constraints ) @@ -44,6 +48,69 @@ class OptimizerTimeslot: TimeslotBlock = list[OptimizerTimeslot] +def merge_blocks( + blocks: Iterable[TimeslotBlock] +) -> Iterable[TimeslotBlock]: + """Merge iterable of blocks together. + + The timeslots of all blocks are grouped into maximal blocks. + Timeslots with the same start and end are identified with each other + and merged (cf `OptimizerTimeslot.merge`). + Throws a ValueError if any timeslots are overlapping but do not + share the same start and end, i.e. partial overlap is not allowed. + + :param blocks: iterable of blocks to merge. + :return: iterable of merged blocks. + :rtype: iterable over lists of OptimizerTimeslot objects + """ + if not blocks: + return [] + + # flatten timeslot iterables to single chain + timeslot_chain = itertools.chain.from_iterable(blocks) + + # sort timeslots according to start + timeslots = sorted( + timeslot_chain, + key=lambda slot: slot.avail.start + ) + + if not timeslots: + return [] + + all_blocks = [] + current_block = [timeslots[0]] + timeslots = timeslots[1:] + + for slot in timeslots: + if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True): + if ( + slot.avail.start == current_block[-1].avail.start + and slot.avail.end == current_block[-1].avail.end + ): + # the same timeslot -> merge + current_block[-1] = current_block[-1].merge(slot) + else: + # partial overlap of interiors -> not supported + # TODO: Show comprehensive message in production + raise ValueError( + "Partially overlapping timeslots are not supported!" + f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})" + ) + elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False): + # only endpoints in intersection -> same block + current_block.append(slot) + else: + # no overlap at all -> new block + all_blocks.append(current_block) + current_block = [slot] + + if current_block: + all_blocks.append(current_block) + + return all_blocks + + class Event(models.Model): """ An event supplies the frame for all Aks. @@ -200,10 +267,10 @@ class Event(models.Model): slot_duration: timedelta, slot_index: int = 0, constraints: set[str] | None = None, - ) -> Iterable[TimeslotBlock]: + ) -> Generator[TimeslotBlock, None, int]: """Discretize a time range into timeslots. - Uses a uniform discretization into blocks of length `slot_duration`, + Uses a uniform discretization into discrete slots 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. @@ -214,7 +281,11 @@ class Event(models.Model): :param slot_index: index of the first timeslot. Defaults to 0. :yield: Block of optimizer timeslots as the discretization result. - :ytype: list of TimeslotBlock + :ytype: list of OptimizerTimeslot + + :return: The first slot index after the yielded blocks, i.e. + `slot_index` + total # generated timeslots + :rtype: int """ # local import to prevent cyclic import # pylint: disable=import-outside-toplevel @@ -264,12 +335,15 @@ class Event(models.Model): return slot_index def uniform_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]: - """Uniformly discretize the entire event into a single block of timeslots. + """Uniformly discretize the entire event into blocks of timeslots. + + Discretizes entire event uniformly. May not necessarily result in a single block + as slots with no room availability are dropped. :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 TimeslotBlock + :ytype: list of OptimizerTimeslot """ all_category_constraints = AKCategory.create_category_constraints( AKCategory.objects.filter(event=self).all() @@ -308,68 +382,6 @@ class Event(models.Model): constraints=category_constraints, ) - def merge_blocks( - self, blocks: Iterable[TimeslotBlock] - ) -> Iterable[TimeslotBlock]: - """Merge iterable of blocks together. - - The timeslots of all blocks are grouped into maximal blocks. - Timeslots with the same start and end are identified with each other - and merged (cf `OptimizerTimeslot.merge`). - Throws a ValueError if any timeslots are overlapping but do not - share the same start and end, i.e. partial overlap is not allowed. - - :param blocks: iterable of blocks to merge. - :return: iterable of merged blocks. - :rtype: iterable over lists of OptimizerTimeslot objects - """ - if not blocks: - return [] - - # flatten timeslot iterables to single chain - timeslot_chain = itertools.chain.from_iterable(blocks) - - # sort timeslots according to start - timeslots = sorted( - timeslot_chain, - key=lambda slot: slot.avail.start - ) - - if not timeslots: - return [] - - all_blocks = [] - current_block = [timeslots[0]] - timeslots = timeslots[1:] - - for slot in timeslots: - if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True): - if ( - slot.avail.start == current_block[-1].avail.start - and slot.avail.end == current_block[-1].avail.end - ): - # the same timeslot -> merge - current_block[-1] = current_block[-1].merge(slot) - else: - # partial overlap of interiors -> not supported - # TODO: Show comprehensive message in production - raise ValueError( - "Partially overlapping timeslots are not supported!" - f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})" - ) - elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False): - # only endpoints in intersection -> same block - current_block.append(slot) - else: - # no overlap at all -> new block - all_blocks.append(current_block) - current_block = [slot] - - if current_block: - all_blocks.append(current_block) - - return all_blocks - def schedule_from_json(self, schedule: str) -> None: """Load AK schedule from a json string. @@ -384,7 +396,7 @@ class Event(models.Model): timeslot_dict = { timeslot.idx: timeslot - for block in self.merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour)) + for block in merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour)) for timeslot in block } @@ -631,7 +643,7 @@ class AK(models.Model): availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event') .filter(ak=self)) detail_string = f"""{self.name}{" (R)" if self.reso else ""}: - + {self.owners_list} {_('Interest')}: {self.interest}""" @@ -925,7 +937,7 @@ class AKSlot(models.Model): # self.slots_in_an_hour is set in AKJSONExportView data = { "id": str(self.pk), - "duration": int(self.duration * self.slots_in_an_hour), + "duration": round(self.duration * self.slots_in_an_hour), "properties": {}, "room_constraints": [constraint.name for constraint in self.ak.requirements.all()], diff --git a/AKModel/tests.py b/AKModel/tests.py index fb24dc08a7891425c24d6017be0a51e25566e52d..6730315d27c81f320f34a7af5a2c3e56c72c3f2c 100644 --- a/AKModel/tests.py +++ b/AKModel/tests.py @@ -214,7 +214,9 @@ class ModelViewTests(BasicViewTests, TestCase): ('admin:event_status', {'event_slug': 'kif42'}), ('admin:event_requirement_overview', {'event_slug': 'kif42'}), ('admin:ak_csv_export', {'event_slug': 'kif42'}), + ('admin:ak_json_export', {'event_slug': 'kif42'}), ('admin:ak_wiki_export', {'slug': 'kif42'}), + ('admin:ak_schedule_json_import', {'event_slug': 'kif42'}), ('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}), ('admin:ak_slide_export', {'event_slug': 'kif42'}), ('admin:default-slots-editor', {'event_slug': 'kif42'}), diff --git a/AKModel/urls.py b/AKModel/urls.py index 9871b4119949d31350ecc64db568d515b61eb3cb..9c10340546b787c92cbd02cb2c3dbbeb6fe1ff94 100644 --- a/AKModel/urls.py +++ b/AKModel/urls.py @@ -5,7 +5,7 @@ from rest_framework.routers import DefaultRouter import AKModel.views.api from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \ - AKsByUserView, AKJSONImportView + AKsByUserView, AKScheduleJSONImportView from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \ AKMessageDeleteView from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \ @@ -99,8 +99,8 @@ def get_admin_urls_event(admin_site): name="ak_csv_export"), path('<slug:event_slug>/ak-json-export/', admin_site.admin_view(AKJSONExportView.as_view()), name="ak_json_export"), - path('<slug:event_slug>/ak-json-import/', admin_site.admin_view(AKJSONImportView.as_view()), - name="ak_json_import"), + path('<slug:event_slug>/ak-schedule-json-import/', admin_site.admin_view(AKScheduleJSONImportView.as_view()), + name="ak_schedule_json_import"), path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()), name="ak_wiki_export"), path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()), diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py index 76bfdb284e42cc71dc9be791a171ba762e2a9b5f..90599a1ba26809c21c17495891ce4c527b827c26 100644 --- a/AKModel/views/ak.py +++ b/AKModel/views/ak.py @@ -9,7 +9,7 @@ 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 +from AKModel.models import AKRequirement, AKSlot, DefaultSlot, Event, AKOrgaMessage, AK, Room, AKOwner, merge_blocks class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): @@ -52,12 +52,15 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): def _test_slot_contained(self, slot: Availability, availabilities: List[Availability]) -> bool: + """Test if slot is contained in any member of availabilities.""" return any(availability.contains(slot) for availability in availabilities) - def _test_event_covered(self, availabilities: List[Availability]) -> bool: + def _test_event_not_covered(self, availabilities: List[Availability]) -> bool: + """Test if event is not covered by availabilities.""" return not Availability.is_event_covered(self.event, availabilities) - def _test_fixed_ak(self, ak_id, slot: Availability, ak_fixed: dict) -> bool: + def _test_ak_fixed_in_slot(self, ak_id, slot: Availability, ak_fixed: dict) -> bool: + """Test if AK defined by `ak_id` is fixed to happen during slot.""" if not ak_id in ak_fixed: return False @@ -65,8 +68,9 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): return fixed_slot.overlaps(slot, strict=True) def _test_add_constraint(self, slot: Availability, availabilities: List[Availability]) -> bool: + """Test if object is not available for whole event and may happen during slot.""" return ( - self._test_event_covered(availabilities) + self._test_event_not_covered(availabilities) and self._test_slot_contained(slot, availabilities) ) @@ -110,27 +114,38 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): if (values := AKSlot.objects.select_related().filter(ak__pk=ak_id, fixed=True)).exists() } - for block in self.event.merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)): + if DefaultSlot.objects.filter(event=self.event).exists(): + # discretize default slots if they exists + blocks = merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)) + else: + blocks = self.event.uniform_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR) + + for block in blocks: current_block = [] for timeslot in block: time_constraints = [] + # if reso_deadline is set and timeslot ends before it, + # add fulfilled time constraint 'resolution' if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline: time_constraints.append("resolution") + # add fulfilled time constraints for all AKs that cannot happen during full event time_constraints.extend([ f"availability-ak-{ak_id}" for ak_id, availabilities in ak_availabilities.items() if ( self._test_add_constraint(timeslot.avail, availabilities) - or self._test_fixed_ak(ak_id, timeslot.avail, ak_fixed) + or self._test_ak_fixed_in_slot(ak_id, timeslot.avail, ak_fixed) ) ]) + # add fulfilled time constraints for all persons that are not available for full event time_constraints.extend([ f"availability-person-{person_id}" for person_id, availabilities in person_availabilities.items() if self._test_add_constraint(timeslot.avail, availabilities) ]) + # add fulfilled time constraints for all rooms that are not available for full event time_constraints.extend([ f"availability-room-{room_id}" for room_id, availabilities in room_availabilities.items() diff --git a/AKModel/views/manage.py b/AKModel/views/manage.py index ec5076fbd850c71a6f7d1a1d202a46461e43ebbd..1bad9534efd6fe81fa70ebf30fbdf84aad476967 100644 --- a/AKModel/views/manage.py +++ b/AKModel/views/manage.py @@ -14,7 +14,7 @@ from django.views.generic import TemplateView, DetailView from django_tex.core import render_template_with_context, run_tex_in_directory from django_tex.response import PDFResponse -from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONImportForm +from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONScheduleImportForm from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner @@ -249,12 +249,12 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView): template_name = "admin/AKModel/aks_by_user.html" -class AKJSONImportView(EventSlugMixin, IntermediateAdminView): +class AKScheduleJSONImportView(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") + form_class = JSONScheduleImportForm + title = _("AK Schedule JSON Import") def form_valid(self, form): self.event.schedule_from_json(form.data["json_data"]) diff --git a/AKModel/views/status.py b/AKModel/views/status.py index 0c12b30348c63d6178f0b5c38d2fcec6cfebf664..f7baa0da13d4ea40002eac18339a494c3dd3e0a5 100644 --- a/AKModel/views/status.py +++ b/AKModel/views/status.py @@ -135,7 +135,7 @@ class EventAKsWidget(TemplateStatusWidget): }, { "text": _("Import AK schedule from JSON"), - "url": reverse_lazy("admin:ak_json_import", kwargs={"event_slug": context["event"].slug}), + "url": reverse_lazy("admin:ak_schedule_json_import", kwargs={"event_slug": context["event"].slug}), }, { "text": _("Export AKs as CSV"),