Skip to content
Snippets Groups Projects
models.py 21 KiB
Newer Older
import itertools
from datetime import timedelta
from django.db import models
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'))
Benjamin Hättasch's avatar
Benjamin Hättasch committed
    timezone = TimeZoneField(default='Europe/Berlin', display_GMT_offset=True, blank=False,
                             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

    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)
    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

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"))
    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


class AKTag(models.Model):
    """ An AKTag is a keyword given to an AK by (one of) its owner(s).
    """
    name = models.CharField(max_length=64, unique=True, verbose_name=_('Name'), help_text=_('Name of the AK Tag'))

    class Meta:
        verbose_name = _('AK Tag')
        verbose_name_plural = _('AK Tags')
        ordering = ['name']

    def __str__(self):
        return self.name


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'))
    tags = models.ManyToManyField(to=AKTag, blank=True, verbose_name=_('Tags'), help_text=_('Tags provided by owners'))
    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,
                                  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 send an e-mail.'))
    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'))
Nadja Geisler's avatar
Nadja Geisler committed
    history = HistoricalRecords()

    class Meta:
        verbose_name = _('AK')
        verbose_name_plural = _('AKs')
        unique_together = [['event', 'name'], ['event', 'short_name']]
        if self.short_name:
            return self.short_name
        return self.name

    @property
    def details(self):
        from AKModel.availability.models import Availability
        availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.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):
        return ", ".join(str(slot.duration) for slot in self.akslot_set.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.save()

    @property
    def availabilities(self):
        return "Availability".objects.filter(ak=self)


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'))
    capacity = models.IntegerField(verbose_name=_('Capacity'), help_text=_('Maximum number of people'))
    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 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"
        """
        if self.start is None:
            return _("Not scheduled yet")
        return self.start.astimezone(self.event.timezone).strftime('%a %H:%M') + ' - ' + self.end.astimezone(self.event.timezone).strftime('%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()


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)

    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', _('AK Slot is scheduled in a room with less space than interest')
        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']
    fields_mm = ['aks', 'ak_slots']

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

    def __str__(self):
        return f"{self.get_level_display()}: {self.get_type_display()} [{self.get_details()}]"