diff --git a/AKModel/models.py b/AKModel/models.py index a49e87f431c7b9395ee2c78ff3027bf786d1a156..e1435c3ef84d57a8385f798a2125a841ed0258d3 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -325,7 +325,8 @@ class AKSlot(models.Model): duration = models.DecimalField(max_digits=4, decimal_places=2, default=2, verbose_name=_('Duration'), help_text=_('Length in hours')) - fixed = models.BooleanField(default=False, verbose_name=_('Scheduling fixed'), help_text=_('Length and time of this AK should not be changed')) + fixed = models.BooleanField(default=False, verbose_name=_('Scheduling fixed'), + help_text=_('Length and time of this AK should not be changed')) event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'), help_text=_('Associated event')) @@ -388,6 +389,9 @@ class AKSlot(models.Model): """ return (timezone.now() - self.updated).total_seconds() + def overlaps(self, other: "AKSlot"): + return self.start <= other.end <= self.end or self.start <= other.start <= self.end + class AKOrgaMessage(models.Model): ak = models.ForeignKey(to=AK, on_delete=models.CASCADE, verbose_name=_('AK'), @@ -459,7 +463,12 @@ class ConstraintViolation(models.Model): help_text=_('Mark this violation manually as resolved')) fields = ['ak_owner', 'room', 'requirement', 'category'] - fields_mm = ['aks', 'ak_slots'] + fields_mm = ['_aks', '_ak_slots'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.aks_tmp = set() + self.ak_slots_tmp = set() def get_details(self): """ @@ -467,10 +476,10 @@ class ConstraintViolation(models.Model): :return: string of details :rtype: str """ - output = [] - # Stringify all ManyToMany fields - for field_mm in self.fields_mm: - output.append(f"{field_mm}: {', '.join(str(a) for a in getattr(self, field_mm).all())}") + # Stringify aks and ak slots fields (m2m) + output = [f"{_('AKs')}: {self._aks_str}", + f"{_('AK Slots')}: {self._ak_slots_str}"] + # Stringify all other fields for field in self.fields: a = getattr(self, field, None) @@ -496,5 +505,86 @@ class ConstraintViolation(models.Model): def timestamp_display(self): return self.timestamp.astimezone(self.event.timezone).strftime('%d.%m.%y %H:%M') + @property + def _aks(self): + """ + Get all AKs belonging to this constraint violation + + The distinction between real and tmp relationships is needed since many to many + relations only work for objects already persisted in the database + + :return: set of all AKs belonging to this constraint violation + :rtype: set(AK) + """ + if self.pk and self.pk > 0: + return set(self.aks.all()) + return self.aks_tmp + + @property + def _aks_str(self): + if self.pk and self.pk > 0: + return ', '.join(str(a) for a in self.aks.all()) + return ', '.join(str(a) for a in self.aks_tmp) + + @property + def _ak_slots(self): + """ + Get all AK Slots belonging to this constraint violation + + The distinction between real and tmp relationships is needed since many to many + relations only work for objects already persisted in the database + + :return: set of all AK Slots belonging to this constraint violation + :rtype: set(AKSlot) + """ + if self.pk and self.pk > 0: + return set(self.ak_slots.all()) + return self.ak_slots_tmp + + @property + def _ak_slots_str(self): + if self.pk and self.pk > 0: + return ', '.join(str(a) for a in self.ak_slots.all()) + return ', '.join(str(a) for a in self.ak_slots_tmp) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + # Store temporary m2m-relations in db + for ak in self.aks_tmp: + self.aks.add(ak) + for ak_slot in self.ak_slots_tmp: + self.ak_slots.add(ak_slot) + def __str__(self): return f"{self.get_level_display()}: {self.get_type_display()} [{self.get_details()}]" + + def matches(self, other): + """ + Check whether one constraint violation instance matches another, + this means has the same type, room, requirement, owner, category + as well as the same lists of aks and ak slots. + PK, timestamp, comments and manual resolving are ignored. + + :param other: second instance to compare to + :type other: ConstraintViolation + :return: true if both instances are similar in the way described, false if not + :rtype: bool + """ + if not isinstance(other, ConstraintViolation): + return False + # Check type + if self.type != other.type: + return False + # Make sure both have the same aks and ak slots + for field_mm in self.fields_mm: + s: set = getattr(self, field_mm) + o: set = getattr(other, field_mm) + if len(s) != len(o): + return False + if len(s.intersection(o)) != len(s): + return False + # Check other "defining" fields + for field in self.fields: + if getattr(self, field) != getattr(other, field): + return False + return True diff --git a/AKScheduling/models.py b/AKScheduling/models.py index 6b2021999398416a78191ac543b7e0e34d86bc2c..3104d90e35db2d0653dabf83e6b08dc0448cfe77 100644 --- a/AKScheduling/models.py +++ b/AKScheduling/models.py @@ -1 +1,238 @@ -# Create your models here. +from django.db.models.signals import post_save, m2m_changed +from django.dispatch import receiver + +from AKModel.availability.models import Availability +from AKModel.models import AK, AKSlot, Room, Event, AKOwner, ConstraintViolation + + +def update_constraint_violations(new_violations, existing_violations_to_check): + """ + Update existing constraint violations (subset for which new violations were computed) based on these new violations. + This will add all new violations without a match, preserve the matching ones + and delete the obsolete ones (those without a match from the newly calculated violations). + + :param new_violations: list of new (not yet saved) violations that exist after the last change + :type new_violations: list[ConstraintViolation] + :param existing_violations_to_check: list of related violations currently in the db + :type existing_violations_to_check: list[ConstraintViolation] + """ + for new_violation in new_violations: + found_match = False + for existing_violation in existing_violations_to_check: + if existing_violation.matches(new_violation): + # Remove from existing violations set since it should stay in db + existing_violations_to_check.remove(existing_violation) + found_match = True + break + + # Only save new violation if no match was found + if not found_match: + new_violation.save() + + # Cleanup obsolete violations (ones without matches computed under current conditions) + for outdated_violation in existing_violations_to_check: + outdated_violation.delete() + + +def update_cv_reso_deadline_for_slot(slot): + """ + Update constraint violation AK_AFTER_RESODEADLINE for given slot + + :param slot: slot to check/update + :type slot: AKSlot + """ + event = slot.event + if slot.ak.reso and slot.event.reso_deadline: + violation_type = ConstraintViolation.ViolationType.AK_AFTER_RESODEADLINE + new_violations = [] + if slot.end > event.reso_deadline: + c = ConstraintViolation( + type=violation_type, + level=ConstraintViolation.ViolationLevel.VIOLATION, + event=event, + ) + c.aks_tmp.add(slot.ak) + c.ak_slots_tmp.add(slot) + new_violations.append(c) + update_constraint_violations(new_violations, list(slot.constraintviolation_set.filter(type=violation_type))) + +@receiver(post_save, sender=AK) +def ak_changed_handler(sender, instance: AK, **kwargs): + # Changes might affect: Owner(s), Requirements, Conflicts, Prerequisites, Category, Interest + pass + + +@receiver(m2m_changed, sender=AK.owners.through) +def ak_changed_handler(sender, instance: AK, action: str, **kwargs): + """ + Owners of AK changed + """ + # Only signal after change (post_add, post_delete, post_clear) are relevant + if not action.startswith("post"): + return + + # print(f"{instance} changed") + + event = instance.event + + # Owner(s) changed: Might affect multiple AKs by the same owner(s) at the same time + violation_type = ConstraintViolation.ViolationType.OWNER_TWO_SLOTS + new_violations = [] + + slots_of_this_ak: [AKSlot] = instance.akslot_set.filter(start__isnull=False) + + # For all owners (after recent change)... + for owner in instance.owners.all(): + # ...find other slots that might be overlapping... + + for ak in owner.ak_set.all(): + # ...find overlapping slots... + if ak != instance: + for slot in slots_of_this_ak: + for other_slot in ak.akslot_set.filter(start__isnull=False): + if slot.overlaps(other_slot): + # ...and create a temporary violation if necessary... + c = ConstraintViolation( + type=violation_type, + level=ConstraintViolation.ViolationLevel.VIOLATION, + event=event, + ak_owner=owner + ) + c.aks_tmp.add(instance) + c.aks_tmp.add(other_slot.ak) + c.ak_slots_tmp.add(slot) + c.ak_slots_tmp.add(other_slot) + new_violations.append(c) + + # print(f"{owner} has the following conflicts: {new_violations}") + + # ... and compare to/update list of existing violations of this type + # belonging to the AK that was recently changed (important!) + existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) + # print(existing_violations_to_check) + update_constraint_violations(new_violations, existing_violations_to_check) + + +@receiver(post_save, sender=AKSlot) +def akslot_changed_handler(sender, instance: AKSlot, **kwargs): + # Changes might affect: Duplicate parallel, Two in room, Resodeadline + print(f"{sender} changed") + event = instance.event + + # == Check for two parallel slots by one of the owners == + + violation_type = ConstraintViolation.ViolationType.OWNER_TWO_SLOTS + new_violations = [] + + # For all owners (after recent change)... + for owner in instance.ak.owners.all(): + # ...find other slots that might be overlapping... + + for ak in owner.ak_set.all(): + # ...find overlapping slots... + if ak != instance.ak: + for other_slot in ak.akslot_set.filter(start__isnull=False): + if instance.overlaps(other_slot): + # ...and create a temporary violation if necessary... + c = ConstraintViolation( + type=violation_type, + level=ConstraintViolation.ViolationLevel.VIOLATION, + event=event, + ak_owner=owner + ) + c.aks_tmp.add(instance.ak) + c.aks_tmp.add(other_slot.ak) + c.ak_slots_tmp.add(instance) + c.ak_slots_tmp.add(other_slot) + new_violations.append(c) + + print(f"{owner} has the following conflicts: {new_violations}") + + # ... and compare to/update list of existing violations of this type + # belonging to the AK that was recently changed (important!) + existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) + print(existing_violations_to_check) + update_constraint_violations(new_violations, existing_violations_to_check) + + # == Check for two aks in the same room at the same time == + + violation_type = ConstraintViolation.ViolationType.ROOM_TWO_SLOTS + new_violations = [] + + # For all slots in this room... + for other_slot in instance.room.akslot_set.all(): + if other_slot != instance: + # ... find overlapping slots... + if instance.overlaps(other_slot): + # ...and create a temporary violation if necessary... + c = ConstraintViolation( + type=violation_type, + level=ConstraintViolation.ViolationLevel.WARNING, + event=event, + room=instance.room + ) + c.aks_tmp.add(instance.ak) + c.aks_tmp.add(other_slot.ak) + c.ak_slots_tmp.add(instance) + c.ak_slots_tmp.add(other_slot) + new_violations.append(c) + + print(f"Multiple slots in room {instance.room}: {new_violations}") + + # ... and compare to/update list of existing violations of this type + # belonging to the slot that was recently changed (important!) + existing_violations_to_check = list(instance.room.constraintviolation_set.filter(type=violation_type)) + print(existing_violations_to_check) + update_constraint_violations(new_violations, existing_violations_to_check) + + # == Check for reso ak after reso deadline == + + update_cv_reso_deadline_for_slot(instance) + + # == Check for two slots of the same AK at the same time (warning) == + + violation_type = ConstraintViolation.ViolationType.AK_SLOT_COLLISION + new_violations = [] + + # For all other slots of this ak... + for other_slot in instance.ak.akslot_set.filter(start__isnull=False): + if other_slot != instance: + # ... find overlapping slots... + if instance.overlaps(other_slot): + # ...and create a temporary violation if necessary... + c = ConstraintViolation( + type=violation_type, + level=ConstraintViolation.ViolationLevel.WARNING, + event=event, + ) + c.aks_tmp.add(instance.ak) + c.ak_slots_tmp.add(instance) + c.ak_slots_tmp.add(other_slot) + new_violations.append(c) + + # ... and compare to/update list of existing violations of this type + # belonging to the slot that was recently changed (important!) + existing_violations_to_check = list(instance.constraintviolation_set.filter(type=violation_type)) + update_constraint_violations(new_violations, existing_violations_to_check) + + +@receiver(post_save, sender=Room) +def room_changed_handler(sender, **kwargs): + # Changes might affect: Room size, Requirement + print(f"{sender} changed") + # TODO Replace with real handling + + +@receiver(post_save, sender=Availability) +def availability_changed_handler(sender, **kwargs): + # Changes might affect: category availability, AK availability, Room availability + print(f"{sender} changed") + # TODO Replace with real handling + + +@receiver(post_save, sender=Event) +def room_changed_handler(sender, instance, **kwargs): + # == Check for reso ak after reso deadline (which might have changed) == + if instance.reso_deadline: + for slot in instance.akslot_set.filter(start__isnull=False, ak__reso=True): + update_cv_reso_deadline_for_slot(slot)