Skip to content
Snippets Groups Projects
models.py 30.2 KiB
Newer Older
import itertools
from datetime import timedelta
from django.db import models
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 _
Nadja Geisler's avatar
Nadja Geisler committed
from simple_history.models import HistoricalRecords
Benjamin Hättasch's avatar
Benjamin Hättasch committed
from timezone_field import TimeZoneField


class Event(models.Model):
    """ An event supplies the frame for all Aks.
    """
    name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'),
                            help_text=_('Name or iteration of the event'))
    slug = models.SlugField(max_length=32, unique=True, verbose_name=_('Short Form'),
                            help_text=_('Short name of letters/numbers/dots/dashes/underscores used in URLs.'))
Benjamin Hättasch's avatar
Benjamin Hättasch committed

    place = models.CharField(max_length=128, blank=True, verbose_name=_('Place'),
                             help_text=_('City etc. the event takes place in'))
    timezone = TimeZoneField(default='Europe/Berlin', blank=False, choices_display="WITH_GMT_OFFSET",
Benjamin Hättasch's avatar
Benjamin Hättasch committed
                             verbose_name=_('Time Zone'), help_text=_('Time Zone where this event takes place in'))
    start = models.DateTimeField(verbose_name=_('Start'), help_text=_('Time the event begins'))
    end = models.DateTimeField(verbose_name=_('End'), help_text=_('Time the event ends'))
    reso_deadline = models.DateTimeField(verbose_name=_('Resolution Deadline'), blank=True, null=True,
                                         help_text=_('When should AKs with intention to submit a resolution be done?'))
Benjamin Hättasch's avatar
Benjamin Hättasch committed

Nadja Geisler's avatar
Nadja Geisler committed
    interest_start = models.DateTimeField(verbose_name=_('Interest Window Start'), blank=True, null=True,
                                          help_text=_('Opening time for expression of interest.'))
    interest_end = models.DateTimeField(verbose_name=_('Interest Window End'), blank=True, null=True,
                                        help_text=_('Closing time for expression of interest.'))

    public = models.BooleanField(verbose_name=_('Public event'), default=True,
                                 help_text=_('Show this event on overview page.'))

    active = models.BooleanField(verbose_name=_('Active State'), help_text=_('Marks currently active events'))
    plan_hidden = models.BooleanField(verbose_name=_('Plan Hidden'), help_text=_('Hides plan for non-staff users'),
                                      default=True)
    plan_published_at = models.DateTimeField(verbose_name=_('Plan published at'), blank=True, null=True,
                                         help_text=_('Timestamp at which the plan was published'))
    base_url = models.URLField(verbose_name=_("Base URL"), help_text=_("Prefix for wiki link construction"), blank=True)
Benjamin Hättasch's avatar
Benjamin Hättasch committed
    wiki_export_template_name = models.CharField(verbose_name=_("Wiki Export Template Name"), blank=True, max_length=50)
Benjamin Hättasch's avatar
Benjamin Hättasch committed
    default_slot = models.DecimalField(max_digits=4, decimal_places=2, default=2, verbose_name=_('Default Slot Length'),
                                       help_text=_('Default length in hours that is assumed for AKs in this event.'))
    contact_email = models.EmailField(verbose_name=_("Contact email address"), blank=True,
                                      help_text=_(
                                          "An email address that is displayed on every page and can be used for all kinds of questions"))
    class Meta:
        verbose_name = _('Event')
        verbose_name_plural = _('Events')
        ordering = ['-start']
    def __str__(self):
        return self.name

    @staticmethod
    def get_by_slug(slug):
        return Event.objects.get(slug=slug)

    @staticmethod
    def get_next_active():
        # Get first active event taking place
        event = Event.objects.filter(active=True).order_by('start').first()
        # No active event? Return the next event taking place
        if event is None:
            event = Event.objects.order_by('start').filter(start__gt=datetime.now()).first()
        return event
    def get_categories_with_aks(self, wishes_seperately=False, filter=lambda ak: True, hide_empty_categories=False):
        """
        Get AKCategories as well as a list of AKs belonging to the category for this event

        :param wishes_seperately: Return wishes as individual list.
        :type wishes_seperately: bool
        :param filter: Optional filter predicate, only include AK in list if filter returns True
        :type filter: (AK)->bool
        :return: list of category-AK-list-tuples, optionally the additional list of AK wishes
        :rtype: list[(AKCategory, list[AK])] [, list[AK]]
        """
Benjamin Hättasch's avatar
Benjamin Hättasch committed
        categories = self.akcategory_set.select_related('event').all()
        categories_with_aks = []
        ak_wishes = []

        if wishes_seperately:
            for category in categories:
                ak_list = []
Benjamin Hättasch's avatar
Benjamin Hättasch committed
                for ak in category.ak_set.select_related('event').prefetch_related('owners', 'akslot_set').all():
                    if filter(ak):
                        if ak.wish:
                            ak_wishes.append(ak)
                        else:
                if not hide_empty_categories or len(ak_list) > 0:
                    categories_with_aks.append((category, ak_list))
            return categories_with_aks, ak_wishes
        else:
            for category in categories:
                ak_list = []
                for ak in category.ak_set.all():
                    if filter(ak):
                        ak_list.append(ak)
                if not hide_empty_categories or len(ak_list) > 0:
                    categories_with_aks.append((category, ak_list))
    def get_unscheduled_wish_slots(self):
        return self.akslot_set.filter(start__isnull=True).annotate(Count('ak__owners')).filter(ak__owners__count=0)
    def get_aks_without_availabilities(self):
        return self.ak_set.annotate(Count('availabilities', distinct=True)).annotate(Count('owners', distinct=True)).filter(availabilities__count=0, owners__count__gt=0)


class AKOwner(models.Model):
    """ An AKOwner describes the person organizing/holding an AK.
    """
    name = models.CharField(max_length=64, verbose_name=_('Nickname'), help_text=_('Name to identify an AK owner by'))
    slug = models.SlugField(max_length=64, blank=True, verbose_name=_('Slug'), help_text=_('Slug for URL generation'))
    institution = models.CharField(max_length=128, blank=True, verbose_name=_('Institution'), help_text=_('Uni etc.'))
    link = models.URLField(blank=True, verbose_name=_('Web Link'), help_text=_('Link to Homepage'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))
        verbose_name = _('AK Owner')
        verbose_name_plural = _('AK Owners')
        ordering = ['name']
        unique_together = [['event', 'name', 'institution'], ['event', 'slug']]
        if self.institution:
            return f"{self.name} ({self.institution})"
        return self.name

    def _generate_slug(self):
        max_length = self._meta.get_field('slug').max_length

        slug_candidate = slugify(self.name)[:max_length]
        if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists():
            self.slug = slug_candidate
            return
        slug_candidate = slugify(slug_candidate + '_' + self.institution)[:max_length]
        if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists():
            self.slug = slug_candidate
            return
        for i in itertools.count(1):
            if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists():
                break
            digits = len(str(i))
            slug_candidate = '{}-{}'.format(slug_candidate[:-(digits + 1)], i)

        self.slug = slug_candidate

    def save(self, *args, **kwargs):
        if not self.slug:
            self._generate_slug()

        super().save(*args, **kwargs)

    @staticmethod
    def get_by_slug(event, slug):
        return AKOwner.objects.get(event=event, slug=slug)
class AKCategory(models.Model):
    """ An AKCategory describes the characteristics of an AK, e.g. content vs. recreational.
    name = models.CharField(max_length=64, verbose_name=_('Name'), help_text=_('Name of the AK Category'))
    color = models.CharField(max_length=7, blank=True, verbose_name=_('Color'), help_text=_('Color for displaying'))
    description = models.TextField(blank=True, verbose_name=_("Description"),
                                   help_text=_("Short description of this AK Category"))
    present_by_default = models.BooleanField(blank=True, default=True, verbose_name=_("Present by default"),
Nadja Geisler's avatar
Nadja Geisler committed
                                             help_text=_(
                                                 "Present AKs of this category by default if AK owner did not specify whether this AK should be presented?"))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
Nadja Geisler's avatar
Nadja Geisler committed
                              help_text=_('Associated event'))

    class Meta:
        verbose_name = _('AK Category')
        verbose_name_plural = _('AK Categories')
        ordering = ['name']
        unique_together = ['event', 'name']
    def __str__(self):
        return self.name


class AKTrack(models.Model):
    """ An AKTrack describes a set of semantically related AKs.
    """
    name = models.CharField(max_length=64, verbose_name=_('Name'), help_text=_('Name of the AK Track'))
    color = models.CharField(max_length=7, blank=True, verbose_name=_('Color'), help_text=_('Color for displaying'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
Nadja Geisler's avatar
Nadja Geisler committed
                              help_text=_('Associated event'))

    class Meta:
        verbose_name = _('AK Track')
        verbose_name_plural = _('AK Tracks')
        ordering = ['name']
        unique_together = ['event', 'name']
    def __str__(self):
        return self.name

Benjamin Hättasch's avatar
Benjamin Hättasch committed
    def aks_with_category(self):
        return self.ak_set.select_related('category').all()


class AKRequirement(models.Model):
    """ An AKRequirement describes something needed to hold an AK, e.g. infrastructure.
    """
    name = models.CharField(max_length=128, verbose_name=_('Name'), help_text=_('Name of the Requirement'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))

    class Meta:
        verbose_name = _('AK Requirement')
        verbose_name_plural = _('AK Requirements')
        ordering = ['name']
        unique_together = ['event', 'name']
    def __str__(self):
        return self.name


class AK(models.Model):
    """ An AK is a slot-based activity to be scheduled during an event.
    """
    name = models.CharField(max_length=256, verbose_name=_('Name'), help_text=_('Name of the AK'))
    short_name = models.CharField(max_length=64, blank=True, verbose_name=_('Short Name'),
                                  help_text=_('Name displayed in the schedule'))
    description = models.TextField(blank=True, verbose_name=_('Description'), help_text=_('Description of the AK'))
Nadja Geisler's avatar
Nadja Geisler committed
    owners = models.ManyToManyField(to=AKOwner, blank=True, verbose_name=_('Owners'),
                                    help_text=_('Those organizing the AK'))

    # TODO generate automatically
    link = models.URLField(blank=True, verbose_name=_('Web Link'), help_text=_('Link to wiki page'))
    protocol_link = models.URLField(blank=True, verbose_name=_('Protocol Link'), help_text=_('Link to protocol'))
    category = models.ForeignKey(to=AKCategory, on_delete=models.PROTECT, verbose_name=_('Category'),
                                 help_text=_('Category of the AK'))
    track = models.ForeignKey(to=AKTrack, blank=True, on_delete=models.SET_NULL, null=True, verbose_name=_('Track'),
                              help_text=_('Track the AK belongs to'))

    reso = models.BooleanField(verbose_name=_('Resolution Intention'), default=False,
                               help_text=_('Intends to submit a resolution'))
    present = models.BooleanField(verbose_name=_("Present this AK"), null=True,
Nadja Geisler's avatar
Nadja Geisler committed
                                  help_text=_("Present results of this AK"))
    requirements = models.ManyToManyField(to=AKRequirement, blank=True, verbose_name=_('Requirements'),
                                          help_text=_("AK's Requirements"))

    conflicts = models.ManyToManyField(to='AK', blank=True, related_name='conflict', verbose_name=_('Conflicting AKs'),
                                       help_text=_('AKs that conflict and thus must not take place at the same time'))
    prerequisites = models.ManyToManyField(to='AK', blank=True, verbose_name=_('Prerequisite AKs'),
                                           help_text=_('AKs that should precede this AK in the schedule'))
Nadja Geisler's avatar
Nadja Geisler committed
    notes = models.TextField(blank=True, verbose_name=_('Organizational Notes'), help_text=_(
        'Notes to organizers. These are public. For private notes, please use the button for private messages on the detail page of this AK (after creation/editing).'))
    interest = models.IntegerField(default=-1, verbose_name=_('Interest'), help_text=_('Expected number of people'))
    interest_counter = models.IntegerField(default=0, verbose_name=_('Interest Counter'),
                                           help_text=_('People who have indicated interest online'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))
    include_in_export = models.BooleanField(default=True, verbose_name=_('Export?'),
                                            help_text=_("Include AK in wiki export?"))

    history = HistoricalRecords(excluded_fields=['interest_counter', 'include_in_export'])
    class Meta:
        verbose_name = _('AK')
        verbose_name_plural = _('AKs')
        unique_together = [['event', 'name'], ['event', 'short_name']]
        ordering = ['pk']
        if self.short_name:
            return self.short_name
        return self.name

    @property
    def details(self):
        from AKModel.availability.models import Availability
Benjamin Hättasch's avatar
Benjamin Hättasch committed
        availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event').filter(ak=self))
        return f"""{self.name}{" (R)" if self.reso else ""}:
        
        {self.owners_list}

        {_("Requirements")}: {", ".join(str(r) for r in self.requirements.all())}  
        {_("Conflicts")}: {", ".join(str(c) for c in self.conflicts.all())}  
        {_("Prerequisites")}: {", ".join(str(p) for p in self.prerequisites.all())}
        {_("Availabilities")}: \n{availabilities}"""
    @property
    def owners_list(self):
        return ", ".join(str(owner) for owner in self.owners.all())

    @property
    def durations_list(self):
Benjamin Hättasch's avatar
Benjamin Hättasch committed
        return ", ".join(str(slot.duration_simplified) for slot in self.akslot_set.select_related('event').all())
Nadja Geisler's avatar
Nadja Geisler committed
    @property
    def wish(self):
        return self.owners.count() == 0

    def increment_interest(self):
        self.interest_counter += 1
        self.skip_history_when_saving = True
        self.save()
        del self.skip_history_when_saving
    @property
    def availabilities(self):
        return "Availability".objects.filter(ak=self)

        if apps.is_installed("AKSubmission"):
            return reverse_lazy('submit:ak_edit', kwargs={'event_slug': self.event.slug, 'pk': self.id})
        return reverse_lazy('admin:AKModel_ak_change', kwargs={'object_id': self.id})

    @property
    def detail_url(self):
        if apps.is_installed("AKSubmission"):
            return reverse_lazy('submit:ak_detail', kwargs={'event_slug': self.event.slug, 'pk': self.id})
        return self.edit_url


class Room(models.Model):
    """ A room describes where an AK can be held.
    """
    name = models.CharField(max_length=64, verbose_name=_('Name'), help_text=_('Name or number of the room'))
    location = models.CharField(max_length=256, blank=True, verbose_name=_('Location'),
                                help_text=_('Name or number of the location'))
Nadja Geisler's avatar
Nadja Geisler committed
    capacity = models.IntegerField(verbose_name=_('Capacity'),
                                   help_text=_('Maximum number of people (-1 for unlimited).'))
    properties = models.ManyToManyField(to=AKRequirement, blank=True, verbose_name=_('Properties'),
                                        help_text=_('AK requirements fulfilled by the room'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))
        verbose_name = _('Room')
        verbose_name_plural = _('Rooms')
        ordering = ['location', 'name']
        unique_together = ['event', 'name', 'location']
Benjamin Hättasch's avatar
Benjamin Hättasch committed
    @property
    def title(self):
        if self.location:
            return f"{self.location} {self.name}"
Benjamin Hättasch's avatar
Benjamin Hättasch committed
    def __str__(self):
        return self.title


class AKSlot(models.Model):
    """ An AK Mapping matches an AK to a room during a certain time.
    """
    ak = models.ForeignKey(to=AK, on_delete=models.CASCADE, verbose_name=_('AK'), help_text=_('AK being mapped'))
    room = models.ForeignKey(to=Room, blank=True, null=True, on_delete=models.SET_NULL, verbose_name=_('Room'),
                             help_text=_('Room the AK will take place in'))
    start = models.DateTimeField(verbose_name=_('Slot Begin'), help_text=_('Time and date the slot begins'),
                                 blank=True, null=True)
    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'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))
    updated = models.DateTimeField(auto_now=True, verbose_name=_("Last update"))

    class Meta:
        verbose_name = _('AK Slot')
        verbose_name_plural = _('AK Slots')
        ordering = ['start', 'room']
            return f"{self.ak} @ {self.start_simplified} in {self.room}"
        return f"{self.ak} @ {self.start_simplified}"

    @property
    def duration_simplified(self):
        """
        Display duration of slot in format hours:minutes, e.g. 1.5 -> "1:30"
        """
        hours, minutes = divmod(self.duration * 60, 60)
        return f"{int(hours)}:{int(minutes):02}"
    @property
    def start_simplified(self):
        """
        Display start time of slot in format weekday + time, e.g. "Fri 14:00"
        """
        if self.start is None:
            return _("Not scheduled yet")
        return self.start.astimezone(self.event.timezone).strftime('%a %H:%M')
    @property
    def time_simplified(self):
        """
        Display start and end time of slot in format weekday + time, e.g. "Fri 14:00 - 15:30" or "Fri 22:00 - Sat 02:00"
        """
        if self.start is None:
            return _("Not scheduled yet")

        start = self.start.astimezone(self.event.timezone)
        end = self.end.astimezone(self.event.timezone)

        return f"{start.strftime('%a %H:%M')} - {end.strftime('%H:%M') if start.day == end.day else end.strftime('%a %H:%M')}"
    @property
    def end(self):
        """
        Retrieve end time of the AK slot
        """
        return self.start + timedelta(hours=float(self.duration))

    @property
    def seconds_since_last_update(self):
        """
        Return minutes since last update
        :return: minutes since last update
        :rtype: float
        """
        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'),
                           help_text=_('AK this message belongs to'))
    text = models.TextField(verbose_name=_("Message text"),
                            help_text=_("Message to the organizers. This is not publicly visible."))
    timestamp = models.DateTimeField(auto_now_add=True)
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))

    class Meta:
        verbose_name = _('AK Orga Message')
        verbose_name_plural = _('AK Orga Messages')
        ordering = ['-timestamp']

    def __str__(self):
        return f'AK Orga Message for "{self.ak}" @ {self.timestamp}'


class ConstraintViolation(models.Model):
    class Meta:
        verbose_name = _('Constraint Violation')
        verbose_name_plural = _('Constraint Violations')
        ordering = ['-timestamp']

    class ViolationType(models.TextChoices):
        OWNER_TWO_SLOTS = 'ots', _('Owner has two parallel slots')
        SLOT_OUTSIDE_AVAIL = 'soa', _('AK Slot was scheduled outside the AK\'s availabilities')
        ROOM_TWO_SLOTS = 'rts', _('Room has two AK slots scheduled at the same time')
        REQUIRE_NOT_GIVEN = 'rng', _('Room does not satisfy the requirement of the scheduled AK')
        AK_CONFLICT_COLLISION = 'acc', _('AK Slot is scheduled at the same time as an AK listed as a conflict')
        AK_BEFORE_PREREQUISITE = 'abp', _('AK Slot is scheduled before an AK listed as a prerequisite')
        AK_AFTER_RESODEADLINE = 'aar', _(
            'AK Slot for AK with intention to submit a resolution is scheduled after resolution deadline')
        AK_CATEGORY_MISMATCH = 'acm', _('AK Slot in a category is outside that categories availabilities')
        AK_SLOT_COLLISION = 'asc', _('Two AK Slots for the same AK scheduled at the same time')
        ROOM_CAPACITY_EXCEEDED = 'rce', _('Room does not have enough space for interest in scheduled AK Slot')
        SLOT_OUTSIDE_EVENT = 'soe', _('AK Slot is scheduled outside the event\'s availabilities')

    class ViolationLevel(models.IntegerChoices):
        WARNING = 1, _('Warning')
        VIOLATION = 10, _('Violation')

    type = models.CharField(verbose_name=_('Type'), max_length=3, choices=ViolationType.choices,
                            help_text=_('Type of violation, i.e. what kind of constraint was violated'))
    level = models.PositiveSmallIntegerField(verbose_name=_('Level'), choices=ViolationLevel.choices,
                                             help_text=_('Severity level of the violation'))
    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))
    aks = models.ManyToManyField(to=AK, blank=True, verbose_name=_('AKs'),
                                 help_text=_('AK(s) belonging to this constraint'))
    ak_slots = models.ManyToManyField(to=AKSlot, blank=True, verbose_name=_('AK Slots'),
                                      help_text=_('AK Slot(s) belonging to this constraint'))
    ak_owner = models.ForeignKey(to=AKOwner, on_delete=models.CASCADE, blank=True, null=True,
                                 verbose_name=_('AK Owner'), help_text=_('AK Owner belonging to this constraint'))
    room = models.ForeignKey(to=Room, on_delete=models.CASCADE, blank=True, null=True, verbose_name=_('Room'),
                             help_text=_('Room belonging to this constraint'))
    requirement = models.ForeignKey(to=AKRequirement, on_delete=models.CASCADE, blank=True, null=True,
                                    verbose_name=_('AK Requirement'),
                                    help_text=_('AK Requirement belonging to this constraint'))
    category = models.ForeignKey(to=AKCategory, on_delete=models.CASCADE, blank=True, null=True,
                                 verbose_name=_('AK Category'), help_text=_('AK Category belonging to this constraint'))

    comment = models.TextField(verbose_name=_('Comment'), help_text=_('Comment or further details for this violation'),
                               blank=True)

    timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp'), help_text=_('Time of creation'))
    manually_resolved = models.BooleanField(verbose_name=_('Manually Resolved'), default=False,
                                            help_text=_('Mark this violation manually as resolved'))
    fields = ['ak_owner', 'room', 'requirement', 'category', 'comment']
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.aks_tmp = set()
        self.ak_slots_tmp = set()

    def get_details(self):
        """
        Get details of this constraint (all fields connected to it)
        :return: string of details
        :rtype: str
        """
        # 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)
            if a is not None and str(a) != '':
                output.append(f"{field}: {a}")
        return ", ".join(output)
    get_details.short_description = _('Details')

    @property
    def details(self):
        return self.get_details()

    @property
    def edit_url(self):
        return reverse_lazy('admin:AKModel_constraintviolation_change', kwargs={'object_id': self.pk})

    @property
    def level_display(self):
        return self.get_level_display()

    @property
    def type_display(self):
        return self.get_type_display()

    @property
    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:
Benjamin Hättasch's avatar
Benjamin Hättasch committed
            return ', '.join(str(a) for a in self.ak_slots.select_related('event').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


class DefaultSlot(models.Model):
    class Meta:
        verbose_name = _('Default Slot')
        verbose_name_plural = _('Default Slots')
        ordering = ['-start']

    start = models.DateTimeField(verbose_name=_('Slot Begin'), help_text=_('Time and date the slot begins'))
    end = models.DateTimeField(verbose_name=_('Slot End'), help_text=_('Time and date the slot ends'))

    event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
                              help_text=_('Associated event'))

    primary_categories = models.ManyToManyField(to=AKCategory, verbose_name=_('Primary categories'), blank=True,
                                            help_text=_('Categories that should be assigned to this slot primarily'))

    @property
    def start_simplified(self):
        return self.start.astimezone(self.event.timezone).strftime('%a %H:%M')

    @property
    def start_iso(self):
        return timezone.localtime(self.start, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")

    @property
    def end_simplified(self):
        return self.end.astimezone(self.event.timezone).strftime('%a %H:%M')

    @property
    def end_iso(self):
        return timezone.localtime(self.end, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")

    def __str__(self):
        return f"{self.event}: {self.start_simplified} - {self.end_simplified}"