diff --git a/AKModel/availability/models.py b/AKModel/availability/models.py index 805465432bfbd7af01ac16b15891dc15f2b0ba1e..35814ee06d2bf49fe9416710c71cbebbb4fc7bb4 100644 --- a/AKModel/availability/models.py +++ b/AKModel/availability/models.py @@ -151,9 +151,12 @@ class Availability(models.Model): if not other.overlaps(self, strict=False): raise Exception('Only overlapping Availabilities can be merged.') - return Availability( + avail = Availability( start=min(self.start, other.start), end=max(self.end, other.end) ) + if self.event == other.event: + avail.event = self.event + return avail def __or__(self, other: 'Availability') -> 'Availability': """Performs the merge operation: ``availability1 | availability2``""" @@ -168,9 +171,12 @@ class Availability(models.Model): if not other.overlaps(self, False): raise Exception('Only overlapping Availabilities can be intersected.') - return Availability( + avail = Availability( start=max(self.start, other.start), end=min(self.end, other.end) ) + if self.event == other.event: + avail.event = self.event + return avail def __and__(self, other: 'Availability') -> 'Availability': """Performs the intersect operation: ``availability1 & diff --git a/AKModel/models.py b/AKModel/models.py index 491594273737d06db119c217a305000b86f9e486..2d58a45651b419899932171a0fdae2a394e85d6b 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -1,5 +1,6 @@ import itertools import json +from dataclasses import dataclass from datetime import datetime, timedelta from typing import Iterable @@ -14,6 +15,35 @@ from simple_history.models import HistoricalRecords from timezone_field import TimeZoneField +@dataclass +class OptimizerTimeslot: + """Class describing a timeslot. Used to interface with an optimizer.""" + + avail: "Availability" + idx: int + constraints: set[str] + + def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot": + """Merge with other OptimizerTimeslot. + + Creates a new OptimizerTimeslot object. + Its availability is constructed by merging the availabilities of self and other, + its constraints by taking the union of both constraint sets. + As an index, the index of self is used. + """ + 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 + ) + + def __repr__(self) -> str: + return f"({self.avail.simplified}, {self.idx}, {self.constraints})" + +TimeslotBlock = list[OptimizerTimeslot] + + class Event(models.Model): """ An event supplies the frame for all Aks. @@ -164,8 +194,13 @@ 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"]]: + self, + start: datetime, + end: datetime, + slot_duration: timedelta, + slot_index: int = 0, + constraints: set[str] | None = None, + ) -> Iterable[TimeslotBlock]: """Discretize a time range into timeslots. Uses a uniform discretization into blocks of length `slot_duration`, @@ -179,8 +214,7 @@ 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 tuples, each consisisting of the timeslot id - and its availability to indicate its start and duration. + :ytype: list of TimeslotBlock """ # local import to prevent cyclic import # pylint: disable=import-outside-toplevel @@ -189,6 +223,9 @@ class Event(models.Model): current_slot_start = start previous_slot_start: datetime | None = None + if constraints is None: + constraints = set() + current_block = [] room_availabilities = list({ @@ -213,7 +250,9 @@ class Event(models.Model): yield current_block current_block = [] - current_block.append((slot_index, slot)) + current_block.append( + OptimizerTimeslot(avail=slot, idx=slot_index, constraints=constraints) + ) previous_slot_start = current_slot_start slot_index += 1 @@ -224,44 +263,113 @@ class Event(models.Model): return slot_index - def uniform_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]: + 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. :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. + :ytype: a single list of TimeslotBlock """ + all_category_constraints = AKCategory.create_category_constraints( + AKCategory.objects.filter(event=self).all() + ) + yield from self._generate_slots_from_block( start=self.start, end=self.end, slot_duration=timedelta(hours=1.0 / slots_in_an_hour), + constraints=all_category_constraints, ) - def default_time_slots(self, *, slots_in_an_hour=1.0) -> Iterable[list[int, "Availability"]]: - """Discretize the all default slots into a blocks of timeslots. + def default_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]: + """Discretize all default slots into 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. + :ytype: list of TimeslotBlock """ 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"): - # NOTE: We do not differentiate between different primary categories + category_constraints = AKCategory.create_category_constraints( + block_slot.primary_categories.all() + ) + slot_index = yield from self._generate_slots_from_block( start=block_slot.start, end=block_slot.end, slot_duration=slot_duration, slot_index=slot_index, + 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. @@ -275,9 +383,9 @@ class Event(models.Model): slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"] timeslot_dict = { - slot_idx: slot - for block in self.default_time_slots(slots_in_an_hour=slots_in_an_hour) - for slot_idx, slot in block + timeslot.idx: timeslot + for block in self.merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour)) + for timeslot in block } for scheduled_slot in schedule["scheduled_aks"]: @@ -286,8 +394,8 @@ class Event(models.Model): scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"])) - start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])] - end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])] + start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail + end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail slot.start = start_timeslot.start slot.duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0 @@ -390,6 +498,20 @@ class AKCategory(models.Model): def __str__(self): return self.name + @staticmethod + def create_category_constraints(categories: Iterable["AKCategory"]) -> set[str]: + """Create a set of constraint strings from an AKCategory iterable. + + :param categories: The iterable of categories to derive the constraint strings from. + :return: A set of category constraint strings, i.e. strings of the form + 'availability-cat-<cat.name>'. + :rtype: set of strings. + """ + return { + f"availability-cat-{cat.name}" + for cat in categories + } + class AKTrack(models.Model): """ An AKTrack describes a set of semantically related AKs. @@ -821,6 +943,10 @@ class AKSlot(models.Model): for owner in self.ak.owners.all(): data["time_constraints"].extend(_owner_time_constraints(owner)) + if self.ak.category: + category_constraints = AKCategory.create_category_constraints([self.ak.category]) + data["time_constraints"].extend(category_constraints) + if self.room is not None and self.fixed: data["room_constraints"].append(f"availability-room-{self.room.pk}") diff --git a/AKModel/views/ak.py b/AKModel/views/ak.py index 818f15d9ebe7e6a08e437f88fb9da6844cda179c..76bfdb284e42cc71dc9be791a171ba762e2a9b5f 100644 --- a/AKModel/views/ak.py +++ b/AKModel/views/ak.py @@ -50,6 +50,7 @@ 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) @@ -69,7 +70,6 @@ class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): and self._test_slot_contained(slot, availabilities) ) - def get_queryset(self): return super().get_queryset().order_by("ak__track") @@ -110,38 +110,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.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR): + for block in self.event.merge_blocks(self.event.default_time_slots(slots_in_an_hour=SLOTS_IN_AN_HOUR)): current_block = [] - for slot_index, slot in block: + for timeslot in block: time_constraints = [] - - if self.event.reso_deadline is None or slot.end < self.event.reso_deadline: + if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline: time_constraints.append("resolution") time_constraints.extend([ f"availability-ak-{ak_id}" for ak_id, availabilities in ak_availabilities.items() if ( - self._test_add_constraint(slot, availabilities) - or self._test_fixed_ak(ak_id, slot, ak_fixed) + self._test_add_constraint(timeslot.avail, availabilities) + or self._test_fixed_ak(ak_id, timeslot.avail, ak_fixed) ) ]) time_constraints.extend([ f"availability-person-{person_id}" for person_id, availabilities in person_availabilities.items() - if self._test_add_constraint(slot, availabilities) + if self._test_add_constraint(timeslot.avail, availabilities) ]) time_constraints.extend([ f"availability-room-{room_id}" for room_id, availabilities in room_availabilities.items() - if self._test_add_constraint(slot, availabilities) + if self._test_add_constraint(timeslot.avail, availabilities) ]) + time_constraints.extend(timeslot.constraints) current_block.append({ - "id": str(slot_index), + "id": str(timeslot.idx), "info": { - "start": slot.simplified, + "start": timeslot.avail.simplified, }, "fulfilled_time_constraints": time_constraints, })