Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • koma/feature/preference-polling-form
  • komasolver
  • main
  • renovate/django_csp-4.x
4 results

Target

Select target project
  • konstantin/akplanning
  • matedealer/akplanning
  • kif/akplanning
  • mirco/akplanning
  • lordofthevoid/akplanning
  • voidptr/akplanning
  • xayomer/akplanning-fork
  • mollux/akplanning
  • neumantm/akplanning
  • mmarx/akplanning
  • nerf/akplanning
  • felix_bonn/akplanning
  • sebastian.uschmann/akplanning
13 results
Select Git revision
  • ak-import
  • feature/clear-schedule-button
  • feature/export-filtering
  • feature/json-export-via-rest-framework
  • feature/json-schedule-import-tests
  • feature/preference-polling-form
  • fix/add-room-import-only-once
  • fix/responsive-cols-in-polls
  • main
  • renovate/django-5.x
  • renovate/django-debug-toolbar-4.x
  • renovate/django-simple-history-3.x
  • renovate/mysqlclient-2.x
13 results
Show changes
Showing
with 1657 additions and 97 deletions
import itertools
from datetime import timedelta
import json
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any, Generator, Iterable
from django.db import models
from django.apps import apps
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import models, transaction
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 _
from simple_history.models import HistoricalRecords
from timezone_field import TimeZoneField
# Custom validators to be used for some of the fields
# Prevent inclusion of the quotation marks ' " ´ `
# This may be necessary to prevent javascript issues
no_quotation_marks_validator = RegexValidator(regex=r"['\"´`]+", inverse_match=True,
message=_('May not contain quotation marks'))
# Enforce that the field contains of at least one letter or digit (and not just special characters
# This prevents issues when autogenerating slugs from that field
slugable_validator = RegexValidator(regex=r"[\w\s]+", message=_('Must contain at least one letter or digit'))
@dataclass
class OptimizerTimeslot:
"""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.
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)
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]
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
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.
"""
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'))
......@@ -31,7 +141,10 @@ class Event(models.Model):
help_text=_('When should AKs with intention to submit a resolution be done?'))
interest_start = models.DateTimeField(verbose_name=_('Interest Window Start'), blank=True, null=True,
help_text=_('Opening time for expression of interest.'))
help_text=
_('Opening time for expression of interest. When left blank, no interest '
'indication will be possible.'))
interest_end = models.DateTimeField(verbose_name=_('Interest Window End'), blank=True, null=True,
help_text=_('Closing time for expression of interest.'))
......@@ -42,16 +155,28 @@ class Event(models.Model):
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'))
help_text=_('Timestamp at which the plan was published'))
poll_hidden = models.BooleanField(verbose_name=_('Poll Hidden'),
help_text=_('Hides preference poll for non-staff users'),
default=True)
poll_published_at = models.DateTimeField(verbose_name=_('Poll published at'), blank=True, null=True,
help_text=_('Timestamp at which the preference poll was published'))
base_url = models.URLField(verbose_name=_("Base URL"), help_text=_("Prefix for wiki link construction"), blank=True)
wiki_export_template_name = models.CharField(verbose_name=_("Wiki Export Template Name"), blank=True, max_length=50)
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.'))
export_slot = models.DecimalField(max_digits=4, decimal_places=2, default=1, verbose_name=_('Export Slot Length'),
help_text=_(
'Slot duration in hours that is used in the timeslot discretization, '
'when this event is exported for the solver.'
))
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"))
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')
......@@ -63,25 +188,44 @@ class Event(models.Model):
@staticmethod
def get_by_slug(slug):
"""
Get event by its slug
:param slug: slug of the event
:return: event identified by the slug
:rtype: Event
"""
return Event.objects.get(slug=slug)
@staticmethod
def get_next_active():
# Get first active event taking place
"""
Get first active event taking place
:return: matching event (if any) or None
:rtype: Event
"""
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()
event = Event.objects.order_by('start').filter(start__gt=datetime.now().astimezone()).first()
return event
def get_categories_with_aks(self, wishes_seperately=False, filter=lambda ak: True, hide_empty_categories=False):
def get_categories_with_aks(self, wishes_seperately=False,
filter_func=lambda ak: True, hide_empty_categories=False,
types=None, types_all_selected_only=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
:param filter_func: Optional filter predicate, only include AK in list if filter returns True
:type filter_func: (AK)->bool
:param hide_empty_categories: If True, categories with no AKs will not be included in the result
:type hide_empty_categories: bool
:param types: Optional list of AK types to filter by, if None, all types are included
:type types: list[AKType] | None
:param types_all_selected_only: If True, only include AKs that have all of the selected types at the same time
:type types_all_selected_only: bool
:return: list of category-AK-list-tuples, optionally the additional list of AK wishes
:rtype: list[(AKCategory, list[AK])] [, list[AK]]
"""
......@@ -89,11 +233,32 @@ class Event(models.Model):
categories_with_aks = []
ak_wishes = []
# Fill lists by iterating
# A different behavior is needed depending on whether wishes should show up inside their categories
# or as a separate category
def _get_category_aks(category, types):
"""
Get all AKs belonging to a category
Use joining and prefetching to reduce the number of necessary SQL queries
:param category: category the AKs should belong to
:return: QuerySet over AKs
:return: QuerySet[AK]
"""
s = category.ak_set
if types is not None:
s = s.filter(types__in=types).distinct()
if types_all_selected_only:
# TODO fix - this only works in very specific cases
s = s.annotate(Count('types')).filter(types__count=len(types))
return s.select_related('event').prefetch_related('owners', 'akslot_set', 'types').all()
if wishes_seperately:
for category in categories:
ak_list = []
for ak in category.ak_set.select_related('event').prefetch_related('owners', 'akslot_set').all():
if filter(ak):
for ak in _get_category_aks(category, types):
if filter_func(ak):
if ak.wish:
ak_wishes.append(ak)
else:
......@@ -101,27 +266,302 @@ class Event(models.Model):
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))
return categories_with_aks
for category in categories:
ak_list = []
for ak in _get_category_aks(category, types):
if filter_func(ak):
ak_list.append(ak)
if not hide_empty_categories or len(ak_list) > 0:
categories_with_aks.append((category, ak_list))
return categories_with_aks
def get_unscheduled_wish_slots(self):
"""
Get all slots of wishes that are currently not scheduled
:return: queryset of theses slots
:rtype: QuerySet[AKSlot]
"""
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)
"""
Gt all AKs that don't have any availability at all
:return: generator over these AKs
:rtype: Generator[AK]
"""
return (self.ak_set
.annotate(Count('availabilities', distinct=True))
.annotate(Count('owners', distinct=True))
.filter(availabilities__count=0, owners__count__gt=0)
)
def _generate_slots_from_block(
self,
start: datetime,
end: datetime,
slot_duration: timedelta,
*,
slot_index: int = 0,
constraints: set[str] | None = None,
) -> Generator[TimeslotBlock, None, int]:
"""Discretize a time range into timeslots.
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.
:param start: Start of the time range.
:param end: Start of the time range.
:param slot_duration: Duration of a single timeslot in the discretization.
:param slot_index: index of the first timeslot. Defaults to 0.
:yield: Block of optimizer timeslots as the discretization result.
: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
from AKModel.availability.models import Availability
current_slot_start = start
previous_slot_start: datetime | None = None
if constraints is None:
constraints = set()
current_block = []
room_availabilities = list({
availability
for room in Room.objects.filter(event=self)
for availability in room.availabilities.all()
})
while current_slot_start + slot_duration <= end:
slot = Availability(
event=self,
start=current_slot_start,
end=current_slot_start + slot_duration,
)
if any((availability.contains(slot) for availability in room_availabilities)):
# no gap in a block
if (
previous_slot_start is not None
and previous_slot_start + slot_duration < current_slot_start
):
yield current_block
current_block = []
current_block.append(
OptimizerTimeslot(avail=slot, idx=slot_index, constraints=constraints)
)
previous_slot_start = current_slot_start
slot_index += 1
current_slot_start += slot_duration
if current_block:
yield current_block
return slot_index
def uniform_time_slots(self, *, slots_in_an_hour: float) -> Iterable[TimeslotBlock]:
"""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: list of OptimizerTimeslot
"""
all_category_constraints = AKCategory.create_category_optimizer_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: float) -> 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 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"):
category_constraints = AKCategory.create_category_optimizer_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 discretize_timeslots(self, *, slots_in_an_hour: float | None = None) -> Iterable[TimeslotBlock]:
""""Choose discretization scheme.
Uses default_time_slots if the event has any DefaultSlot, otherwise uniform_time_slots.
: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 TimeslotBlock
"""
if slots_in_an_hour is None:
slots_in_an_hour = 1.0 / float(self.export_slot)
if DefaultSlot.objects.filter(event=self).exists():
# discretize default slots if they exists
yield from merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour))
else:
yield from self.uniform_time_slots(slots_in_an_hour=slots_in_an_hour)
@transaction.atomic
def schedule_from_json(
self, schedule: str | dict[str, Any], *, check_for_data_inconsistency: bool = True
) -> int:
"""Load AK schedule from a json string.
:param schedule: A string that can be decoded to json, describing
the AK schedule. The json data is assumed to be constructed
following the output specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
"""
if isinstance(schedule, str):
schedule = json.loads(schedule)
if "input" not in schedule or "scheduled_aks" not in schedule:
raise ValueError(_("Cannot parse malformed JSON input."))
if apps.is_installed("AKSolverInterface") and check_for_data_inconsistency:
from AKSolverInterface.serializers import ExportEventSerializer # pylint: disable=import-outside-toplevel
export_dict = ExportEventSerializer(self).data
if schedule["input"] != export_dict:
raise ValueError(_("Data has changed since the export. Reexport and run the solver again."))
slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"]
timeslot_dict = {
timeslot.idx: timeslot
for block in self.discretize_timeslots(slots_in_an_hour=slots_in_an_hour)
for timeslot in block
}
slots_updated = 0
for scheduled_slot in schedule["scheduled_aks"]:
scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"]))
slot = AKSlot.objects.get(id=int(scheduled_slot["ak_id"]))
if not scheduled_slot["timeslot_ids"]:
raise ValueError(
_("AK {ak_name} is not assigned any timeslot by the solver").format(ak_name=slot.ak.name)
)
start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail
end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail
solver_duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0
if solver_duration + 2e-4 < slot.duration:
raise ValueError(
_(
"Duration of AK {ak_name} assigned by solver ({solver_duration} hours) "
"is less than the duration required by the slot ({slot_duration} hours)"
).format(
ak_name=slot.ak.name,
solver_duration=solver_duration,
slot_duration=slot.duration,
)
)
if slot.fixed:
solver_room = Room.objects.get(id=int(scheduled_slot["room_id"]))
if slot.room != solver_room:
raise ValueError(
_(
"Fixed AK {ak_name} assigned by solver to room {solver_room} "
"is fixed to room {slot_room}"
).format(
ak_name=slot.ak.name,
solver_room=solver_room.name,
slot_room=slot.room.name,
)
)
if slot.start != start_timeslot.start:
raise ValueError(
_(
"Fixed AK {ak_name} assigned by solver to start at {solver_start} "
"is fixed to start at {slot_start}"
).format(
ak_name=slot.ak.name,
solver_start=start_timeslot.start,
slot_start=slot.start,
)
)
else:
slot.room = Room.objects.get(id=int(scheduled_slot["room_id"]))
slot.start = start_timeslot.start
slot.save()
slots_updated += 1
return slots_updated
@property
def rooms(self):
"""Ordered queryset of all rooms associated to this event."""
return Room.objects.filter(event=self).order_by()
@property
def slots(self):
"""Ordered queryset of all AKSlots associated to this event."""
return AKSlot.objects.filter(event=self).order_by()
@property
def participants(self):
"""Ordered queryset of all participants associated to this event."""
if apps.is_installed("AKPreference"):
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKPreference.models import EventParticipant
return EventParticipant.objects.filter(event=self).order_by()
return []
@property
def owners(self):
"""Ordered queryset of all AK owners associated to this event."""
return AKOwner.objects.filter(event=self).order_by()
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'))
name = models.CharField(max_length=64, verbose_name=_('Nickname'),
validators=[no_quotation_marks_validator, slugable_validator],
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'))
......@@ -141,21 +581,34 @@ class AKOwner(models.Model):
return self.name
def _generate_slug(self):
"""
Auto-generate a slug for an owner
This will start with a very simple slug (the name truncated to a maximum length) and then gradually produce
more complicated slugs when the previous candidates are already used
:return: the slug
:rtype: str
"""
max_length = self._meta.get_field('slug').max_length
# Try name alone (truncated if necessary)
slug_candidate = slugify(self.name)[:max_length]
if not AKOwner.objects.filter(event=self.event, slug=slug_candidate).exists():
self.slug = slug_candidate
return
# Try name and institution separated by '_' (truncated if necessary)
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
# Try name + institution + an incrementing digit
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)
slug_candidate = f'{slug_candidate[:-(digits + 1)]}-{i}'
self.slug = slug_candidate
......@@ -167,6 +620,15 @@ class AKOwner(models.Model):
@staticmethod
def get_by_slug(event, slug):
"""
Get owner by slug
Will be identified by the combination of event slug and owner slug which is unique
:param event: event
:param slug: slug of the owner
:return: owner identified by slugs
:rtype: AKOwner
"""
return AKOwner.objects.get(event=event, slug=slug)
......@@ -178,8 +640,8 @@ class AKCategory(models.Model):
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"),
help_text=_(
"Present AKs of this category by default if AK owner did not specify whether this AK should be presented?"))
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'),
help_text=_('Associated event'))
......@@ -193,6 +655,20 @@ class AKCategory(models.Model):
def __str__(self):
return self.name
@staticmethod
def create_category_optimizer_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.
......@@ -213,6 +689,11 @@ class AKTrack(models.Model):
return self.name
def aks_with_category(self):
"""
Get all AKs that belong to this track with category already joined to prevent additional SQL queries
:return: queryset over the AKs
:rtype: QuerySet[AK]
"""
return self.ak_set.select_related('category').all()
......@@ -220,6 +701,8 @@ 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'))
relevant_for_participants = models.BooleanField(default=False, verbose_name=_('Relevant for Participants?'),
help_text=_('Show this requirement when collecting participant preferences'))
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
......@@ -234,23 +717,46 @@ class AKRequirement(models.Model):
return self.name
class AKType(models.Model):
""" An AKType allows to associate one or multiple types with an AK, e.g., to better describe the format of that AK
or to which group of people it is addressed. Types are specified per event and are an optional feature.
"""
name = models.CharField(max_length=128, verbose_name=_('Name'), help_text=_('Name describing the type'))
slug = models.SlugField(max_length=30, blank=False, verbose_name=_('Slug'),)
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
class Meta:
verbose_name = _('AK Type')
verbose_name_plural = _('AK Types')
ordering = ['name']
unique_together = [['event', 'name'], ['event', 'slug']]
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'))
name = models.CharField(max_length=256, verbose_name=_('Name'), help_text=_('Name of the AK'),
validators=[no_quotation_marks_validator, slugable_validator])
short_name = models.CharField(max_length=64, blank=True, verbose_name=_('Short Name'),
validators=[no_quotation_marks_validator],
help_text=_('Name displayed in the schedule'))
description = models.TextField(blank=True, verbose_name=_('Description'), help_text=_('Description of the AK'))
owners = models.ManyToManyField(to=AKOwner, blank=True, verbose_name=_('Owners'),
help_text=_('Those organizing the AK'))
# TODO generate automatically
# Will be automatically generated in save method if not set
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'))
types = models.ManyToManyField(to=AKType, blank=True, verbose_name=_('Types'),
help_text=_("This AK is"))
track = models.ForeignKey(to=AKTrack, blank=True, on_delete=models.SET_NULL, null=True, verbose_name=_('Track'),
help_text=_('Track the AK belongs to'))
......@@ -268,7 +774,8 @@ class AK(models.Model):
help_text=_('AKs that should precede this AK in the schedule'))
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).'))
'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'),
......@@ -280,12 +787,13 @@ class AK(models.Model):
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'])
history = HistoricalRecords(excluded_fields=['interest', 'interest_counter', 'include_in_export'])
class Meta:
verbose_name = _('AK')
verbose_name_plural = _('AKs')
unique_together = [['event', 'name'], ['event', 'short_name']]
ordering = ['pk']
def __str__(self):
if self.short_name:
......@@ -294,51 +802,142 @@ class AK(models.Model):
@property
def details(self):
"""
Generate a detailed string representation, e.g., for usage in scheduling
:return: string representation of that AK with all details
:rtype: str
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
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 ""}:
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}
{_("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}"""
{_('Interest')}: {self.interest}"""
if self.requirements.count() > 0:
detail_string += f"\n{_('Requirements')}: {', '.join(str(r) for r in self.requirements.all())}"
if self.types.count() > 0:
detail_string += f"\n{_('Types')}: {', '.join(str(r) for r in self.types.all())}"
# Find conflicts
# (both directions, those specified for this AK and those were this AK was specified as conflict)
# Deduplicate and order list alphabetically
conflicts = set()
if self.conflicts.count() > 0:
for c in self.conflicts.all():
conflicts.add(str(c))
if self.conflict.count() > 0:
for c in self.conflict.all():
conflicts.add(str(c))
if len(conflicts) > 0:
conflicts = list(conflicts)
conflicts.sort()
detail_string += f"\n{_('Conflicts')}: {', '.join(conflicts)}"
if self.prerequisites.count() > 0:
detail_string += f"\n{_('Prerequisites')}: {', '.join(str(p) for p in self.prerequisites.all())}"
detail_string += f"\n{_('Availabilities')}: \n{availabilities}"
return detail_string
@property
def owners_list(self):
"""
Get a stringified list of stringified representations of all owners
:return: stringified list of owners
:rtype: str
"""
return ", ".join(str(owner) for owner in self.owners.all())
@property
def durations_list(self):
"""
Get a stringified list of stringified representations of all durations of associated slots
:return: stringified list of durations
:rtype: str
"""
return ", ".join(str(slot.duration_simplified) for slot in self.akslot_set.select_related('event').all())
@property
def types_list(self):
"""
Get a stringified list of all types of this AK
:return: stringified list of types
:rtype: str
"""
return ", ".join(str(t) for t in self.types.all())
@property
def wish(self):
"""
Is the AK a wish?
:return: true if wish, false if not
:rtype: bool
"""
return self.owners.count() == 0
def increment_interest(self):
"""
Increment the interest counter for this AK by one
without tracking that change to prevent an unreadable and large history
"""
self.interest_counter += 1
self.skip_history_when_saving = True
self.skip_history_when_saving = True # pylint: disable=attribute-defined-outside-init
self.save()
del self.skip_history_when_saving
@property
def availabilities(self):
"""
Get all availabilities associated to this AK
:return: availabilities
:rtype: QuerySet[Availability]
"""
return "Availability".objects.filter(ak=self)
@property
def edit_url(self):
"""
Get edit URL for this AK
Will link to frontend if AKSubmission is active, otherwise to the edit view for this object in admin interface
:return: URL
:rtype: str
"""
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):
"""
Get detail URL for this AK
Will link to frontend if AKSubmission is active, otherwise to the edit view for this object in admin interface
:return: URL
:rtype: str
"""
if apps.is_installed("AKSubmission"):
return reverse_lazy('submit:ak_detail', kwargs={'event_slug': self.event.slug, 'pk': self.id})
return self.edit_url
def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
# Auto-Generate Link if not set yet
if self.link == "":
link = self.event.base_url + self.name.replace(" ", "_")
# Truncate links longer than 200 characters (default length of URL fields in django)
self.link = link[:200]
# Tell Django that we have updated the link field
if update_fields is not None:
update_fields = {"link"}.union(update_fields)
super().save(*args,
force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
class Room(models.Model):
""" A room describes where an AK can be held.
......@@ -362,6 +961,12 @@ class Room(models.Model):
@property
def title(self):
"""
Get title of a room, which consists of location and name if location is set, otherwise only the name
:return: title
:rtype: str
"""
if self.location:
return f"{self.location} {self.name}"
return self.name
......@@ -369,6 +974,32 @@ class Room(models.Model):
def __str__(self):
return self.title
def get_time_constraints(self) -> list[str]:
"""Construct list of required time constraint labels."""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
# check if room is available for the whole event
# -> no time constraint needs to be introduced
if Availability.is_event_covered(self.event, self.availabilities.all()):
time_constraints = []
else:
time_constraints = [f"availability-room-{self.pk}"]
return time_constraints
def get_fulfilled_room_constraints(self) -> list[str]:
"""Construct list of fulfilled room constraint labels."""
fulfilled_room_constraints = list(self.properties.values_list("name", flat=True))
fulfilled_room_constraints.append(f"fixed-room-{self.pk}")
if not any(constr.startswith("proxy") for constr in fulfilled_room_constraints):
fulfilled_room_constraints.append("no-proxy")
fulfilled_room_constraints.sort()
return fulfilled_room_constraints
class AKSlot(models.Model):
""" An AK Mapping matches an AK to a room during a certain time.
......@@ -427,7 +1058,8 @@ class AKSlot(models.Model):
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')}"
return (f"{start.strftime('%a %H:%M')} - "
f"{end.strftime('%H:%M') if start.day == end.day else end.strftime('%a %H:%M')}")
@property
def end(self):
......@@ -446,10 +1078,103 @@ class AKSlot(models.Model):
return (timezone.now() - self.updated).total_seconds()
def overlaps(self, other: "AKSlot"):
"""
Check whether two slots overlap
:param other: second slot to compare with
:return: true if they overlap, false if not:
:rtype: bool
"""
return self.start < other.end <= self.end or self.start <= other.start < self.end
def save(self, *args, force_insert=False, force_update=False, using=None, update_fields=None):
# Make sure duration is not longer than the event
if update_fields is None or 'duration' in update_fields:
event_duration = self.event.end - self.event.start
event_duration_hours = event_duration.days * 24 + event_duration.seconds // 3600
self.duration = min(self.duration, event_duration_hours)
super().save(*args,
force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields)
def get_room_constraints(self, export_scheduled_aks_as_fixed: bool = False) -> list[str]:
"""Construct list of required room constraint labels."""
room_constraints = list(self.ak.requirements.values_list("name", flat=True).order_by())
if (export_scheduled_aks_as_fixed or self.fixed) and self.room is not None:
room_constraints.append(f"fixed-room-{self.room.pk}")
if not any(constr.startswith("proxy") for constr in room_constraints):
room_constraints.append("no-proxy")
room_constraints.sort()
return room_constraints
def get_time_constraints(self, export_scheduled_aks_as_fixed: bool = False) -> list[str]:
"""Construct list of required time constraint labels."""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
def _owner_time_constraints(owner: AKOwner):
owner_avails = owner.availabilities.all()
if not owner_avails or Availability.is_event_covered(self.event, owner_avails):
return []
return [f"availability-person-{owner.pk}"]
# check if ak resp. owner is available for the whole event
# -> no time constraint needs to be introduced
if (export_scheduled_aks_as_fixed or self.fixed) and self.start is not None:
time_constraints = [f"fixed-akslot-{self.id}"]
elif not Availability.is_event_covered(self.event, self.ak.availabilities.all()):
time_constraints = [f"availability-ak-{self.ak.pk}"]
else:
time_constraints = []
if self.ak.reso:
time_constraints.append("resolution")
for owner in self.ak.owners.all():
time_constraints.extend(_owner_time_constraints(owner))
if self.ak.category:
category_constraints = AKCategory.create_category_optimizer_constraints([self.ak.category])
time_constraints.extend(category_constraints)
time_constraints.sort()
return time_constraints
@property
def export_duration(self) -> int:
"""Number of discrete export timeslots covered by this AKSlot."""
export_duration = self.duration / self.event.export_slot
# We need to return an int, so we round up.
# If the exact result for `export_duration` is an integer `k`,
# FLOP inaccuracies could yield `k + eps`. Then, rounding up
# would return `k + 1` instead of `k`. To avoid this, we subtract
# a small epsilon before rounding.
return math.ceil(export_duration - settings.EXPORT_CEIL_OFFSET_EPS)
@property
def type_names(self):
"""Ordered queryset of the names of all types of this slot's AK."""
return self.ak.types.values_list("name", flat=True).order_by()
@property
def conflict_pks(self) -> list[int]:
"""Ordered queryset of the PKs of all AKSlots that in conflict to this slot."""
conflict_slots = AKSlot.objects.filter(ak__in=self.ak.conflicts.all())
other_ak_slots = AKSlot.objects.filter(ak=self.ak).exclude(pk=self.pk)
return list((conflict_slots | other_ak_slots).values_list("pk", flat=True).order_by())
@property
def depencency_pks(self) -> list[int]:
"""Ordered queryset of the PKs of all AKSlots that this slot depends on."""
dependency_slots = AKSlot.objects.filter(ak__in=self.ak.prerequisites.all())
return list(dependency_slots.values_list("pk", flat=True).order_by())
class AKOrgaMessage(models.Model):
"""
Model representing confidential messages to the organizers/scheduling people, belonging to a certain AK
"""
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"),
......@@ -457,6 +1182,8 @@ class AKOrgaMessage(models.Model):
timestamp = models.DateTimeField(auto_now_add=True)
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
resolved = models.BooleanField(verbose_name=_('Resolved'), default=False,
help_text=_('This message has been resolved (no further action needed)'))
class Meta:
verbose_name = _('AK Orga Message')
......@@ -468,12 +1195,24 @@ class AKOrgaMessage(models.Model):
class ConstraintViolation(models.Model):
"""
Model to represent any kind of constraint violation
Can have two different severities: violation and warning
The list of possible types is defined in :class:`ViolationType`
Depending on the type, different fields (references to other models) will be filled. Each violation should always
be related to an event and at least on other instance of a causing entity
"""
class Meta:
verbose_name = _('Constraint Violation')
verbose_name_plural = _('Constraint Violations')
ordering = ['-timestamp']
class ViolationType(models.TextChoices):
"""
Possible types of violations with their text representation
"""
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')
......@@ -481,13 +1220,16 @@ class ConstraintViolation(models.Model):
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 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):
"""
Possible severities/levels of a CV
"""
WARNING = 1, _('Warning')
VIOLATION = 10, _('Violation')
......@@ -499,6 +1241,7 @@ class ConstraintViolation(models.Model):
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
# Possible "causes":
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'),
......@@ -549,22 +1292,37 @@ class ConstraintViolation(models.Model):
@property
def details(self):
"""
Property: Details
"""
return self.get_details()
@property
def edit_url(self):
def edit_url(self) -> str:
"""
Property: Edit URL for this CV
"""
return reverse_lazy('admin:AKModel_constraintviolation_change', kwargs={'object_id': self.pk})
@property
def level_display(self):
def level_display(self) -> str:
"""
Property: Severity as string
"""
return self.get_level_display()
@property
def type_display(self):
def type_display(self) -> str:
"""
Property: Type as string
"""
return self.get_type_display()
@property
def timestamp_display(self):
def timestamp_display(self) -> str:
"""
Property: Creation timestamp as string
"""
return self.timestamp.astimezone(self.event.timezone).strftime('%d.%m.%y %H:%M')
@property
......@@ -583,7 +1341,10 @@ class ConstraintViolation(models.Model):
return self.aks_tmp
@property
def _aks_str(self):
def _aks_str(self) -> str:
"""
Property: AKs as string
"""
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)
......@@ -604,7 +1365,10 @@ class ConstraintViolation(models.Model):
return self.ak_slots_tmp
@property
def _ak_slots_str(self):
def _ak_slots_str(self) -> str:
"""
Property: Slots as string
"""
if self.pk and self.pk > 0:
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)
......@@ -653,6 +1417,11 @@ class ConstraintViolation(models.Model):
class DefaultSlot(models.Model):
"""
Model representing a default slot,
i.e., a prefered slot to use for typical AKs in the schedule to guarantee enough breaks etc.
"""
class Meta:
verbose_name = _('Default Slot')
verbose_name_plural = _('Default Slots')
......@@ -665,22 +1434,35 @@ class DefaultSlot(models.Model):
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'))
help_text=_(
'Categories that should be assigned to this slot primarily'))
@property
def start_simplified(self):
def start_simplified(self) -> str:
"""
Property: Simplified version of the start timetstamp (weekday, hour, minute) as string
"""
return self.start.astimezone(self.event.timezone).strftime('%a %H:%M')
@property
def start_iso(self):
def start_iso(self) -> str:
"""
Property: Start timestamp as ISO timestamp for usage in calendar views
"""
return timezone.localtime(self.start, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")
@property
def end_simplified(self):
def end_simplified(self) -> str:
"""
Property: Simplified version of the end timetstamp (weekday, hour, minute) as string
"""
return self.end.astimezone(self.event.timezone).strftime('%a %H:%M')
@property
def end_iso(self):
def end_iso(self) -> str:
"""
Property: End timestamp as ISO timestamp for usage in calendar views
"""
return timezone.localtime(self.end, self.event.timezone).strftime("%Y-%m-%dT%H:%M:%S")
def __str__(self):
......
from rest_framework import serializers
from AKModel.models import AK, Room, AKSlot, AKTrack, AKCategory, AKOwner
from AKModel.models import AK, AKCategory, AKOwner, AKSlot, AKTrack, Room
class StringListField(serializers.ListField):
"""List field containing strings."""
child = serializers.CharField()
class IntListField(serializers.ListField):
"""List field containing integers."""
child = serializers.IntegerField()
class AKOwnerSerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for AKOwner
"""
class Meta:
model = AKOwner
fields = '__all__'
class AKCategorySerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for AKCategory
"""
class Meta:
model = AKCategory
fields = '__all__'
class AKTrackSerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for AKTrack
"""
class Meta:
model = AKTrack
fields = '__all__'
class AKSerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for AK
"""
class Meta:
model = AK
fields = '__all__'
class RoomSerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for Room
"""
class Meta:
model = Room
fields = '__all__'
class AKSlotSerializer(serializers.ModelSerializer):
"""
REST Framework Serializer for AKSlot
"""
class Meta:
model = AKSlot
fields = '__all__'
......@@ -41,6 +71,9 @@ class AKSlotSerializer(serializers.ModelSerializer):
treat_as_local = serializers.BooleanField(required=False, default=False, write_only=True)
def create(self, validated_data:dict):
# Handle timezone adaption based upon the control field "treat_as_local":
# If it is set, ignore timezone submitted from the browser (will always be UTC)
# and treat it as input in the events timezone instead
if validated_data['treat_as_local']:
validated_data['start'] = validated_data['start'].replace(tzinfo=None).astimezone(
validated_data['event'].timezone)
......
from django.contrib.admin import AdminSite
from django.utils.translation import gettext_lazy as _
# from django.urls import path
from AKModel.models import Event
class AKAdminSite(AdminSite):
"""
Custom admin interface definition (extend the admin functionality of Django)
"""
index_template = "admin/ak_index.html"
site_header = f"AKPlanning - {_('Administration')}"
index_title = _('Administration')
def get_urls(self):
from django.urls import path
"""
Get URLs -- add further views that are not related to a certain model here if needed
"""
urls = super().get_urls()
urls += [
# path('...', self.admin_view(...)),
......@@ -19,6 +24,8 @@ class AKAdminSite(AdminSite):
return urls
def index(self, request, extra_context=None):
# Override index page rendering to provide extra context (the list of active events)
# to be used in the adapted template
if extra_context is None:
extra_context = {}
extra_context["active_events"] = Event.objects.filter(active=True)
......
......@@ -4,8 +4,8 @@
{% block content %}
<pre>
title;duration;who;requirements;prerequisites;conflicts;availabilities;category;track;reso;notes;
{% for slot in slots %}{{ slot.ak.short_name }};{{ slot.duration }};{{ slot.ak.owners.all|join:", " }};{{ slot.ak.requirements.all|join:", " }};{{ slot.ak.prerequisites.all|join:", " }};{{ slot.ak.conflicts.all|join:", " }};{% for a in slot.ak.availabilities.all %}{{ a.start | timezone:event.timezone | date:"l H:i" }} - {{ a.end | timezone:event.timezone | date:"l H:i" }}, {% endfor %};{{ slot.ak.category }};{{ slot.ak.track }};{{ slot.ak.reso }};{{ slot.ak.notes }};
title;duration;who;requirements;prerequisites;conflicts;availabilities;category;types;track;reso;notes;
{% for slot in slots %}{{ slot.ak.short_name }};{{ slot.duration }};{{ slot.ak.owners.all|join:", " }};{{ slot.ak.requirements.all|join:", " }};{{ slot.ak.prerequisites.all|join:", " }};{{ slot.ak.conflicts.all|join:", " }};{% for a in slot.ak.availabilities.all %}{{ a.start | timezone:event.timezone | date:"l H:i" }} - {{ a.end | timezone:event.timezone | date:"l H:i" }}, {% endfor %};{{ slot.ak.category }};{{ slot.ak.types.all|join:", " }};{{ slot.ak.track }};{{ slot.ak.reso }};{{ slot.ak.notes }};
{% endfor %}
</pre>
{% endblock %}
{% extends "admin/base_site.html" %}
{% load tags_AKModel %}
{% load i18n %}
{% load tz %}
{% load fontawesome_6 %}
{% block title %}{% trans "AKs by Owner" %}: {{owner}}{% endblock %}
{% block content %}
{% timezone event.timezone %}
<h2>[{{event}}] <a href="{% url 'admin:AKModel_akowner_change' owner.pk %}">{{owner}}</a> - {% trans "AKs" %}</h2>
<div class="row mt-4">
<table class="table table-striped">
{% for ak in owner.ak_set.all %}
<tr>
<td>{{ ak }}</td>
{% if "AKSubmission"|check_app_installed %}
<td class="text-end">
<a href="{{ ak.detail_url }}" data-bs-toggle="tooltip"
title="{% trans 'Details' %}"
class="btn btn-primary">{% fa6_icon 'info' 'fas' %}</a>
{% if event.active %}
<a href="{{ ak.edit_url }}" data-bs-toggle="tooltip"
title="{% trans 'Edit' %}"
class="btn btn-success">{% fa6_icon 'pencil-alt' 'fas' %}</a>
{% endif %}
{% endif %}
</td>
</tr>
{% empty %}
<tr><td>{% trans "This user does not have any AKs currently" %}</td></tr>
{% endfor %}
</table>
</div>
{% endtimezone %}
{% endblock %}
......@@ -8,6 +8,11 @@
{% block title %}{% trans "New event wizard" %}: {{ wizard_step_text }}{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block content %}
{% include "admin/AKModel/event_wizard/wizard_steps.html" %}
......@@ -17,8 +22,6 @@
<h5 class="mb-3">{% trans "Successfully imported.<br><br>Do you want to activate your event now?" %}</h5>
{{ form.media }}
<form method="post">{% csrf_token %}
{% bootstrap_form form %}
......
......@@ -8,6 +8,11 @@
{% block title %}{% trans "New event wizard" %}: {{ wizard_step_text }}{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block content %}
{% include "admin/AKModel/event_wizard/wizard_steps.html" %}
......@@ -29,8 +34,6 @@
<h5 class="mb-3">{% trans "Your event was created and can now be further configured." %}</h5>
{{ form.media }}
<form method="post">{% csrf_token %}
{% bootstrap_form form %}
......
......@@ -8,11 +8,14 @@
{% block title %}{% trans "New event wizard" %}: {{ wizard_step_text }}{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block content %}
{% include "admin/AKModel/event_wizard/wizard_steps.html" %}
{{ form.media }}
<form method="post">{% csrf_token %}
{% bootstrap_form form %}
......
......@@ -8,11 +8,14 @@
{% block title %}{% trans "New event wizard" %}: {{ wizard_step_text }}{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block content %}
{% include "admin/AKModel/event_wizard/wizard_steps.html" %}
{{ form.media }}
{% timezone timezone %}
<form method="post">{% csrf_token %}
......
......@@ -7,6 +7,11 @@
{% block title %}{% trans "New event wizard" %}: {{ wizard_step_text }}{% endblock %}
{% block extrahead %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block content %}
{% include "admin/AKModel/event_wizard/wizard_steps.html" %}
......
......@@ -19,6 +19,10 @@
\faUser~ {{ translations.who }}
{% if show_types %}
\faList~ {{ translations.types }}
{% endif %}
\faClock~ {{ translations.duration }}
\faScroll~{{ translations.reso }}
......@@ -45,6 +49,10 @@
\faUser~ {{ ak.owners_list | latex_escape }}
{% if show_types %}
\faList~ {{ak.types_list }}
{% endif %}
{% if not result_presentation_mode %}
\faClock~ {{ak.durations_list}}
{% endif %}
......
{% load tz %}
{% load fontawesome_6 %}
{% timezone event.timezone %}
<table class="table table-striped">
......@@ -7,7 +8,10 @@
<span class="text-secondary float-end">
{{ message.timestamp|date:"Y-m-d H:i:s" }}
</span>
<h5><a href="{{ message.ak.detail_url }}">{{ message.ak }}</a></h5>
<h5><a href="{{ message.ak.detail_url }}">
{% if message.resolved %}{% fa6_icon "check-circle" %} {% endif %}
{{ message.ak }}
</a></h5>
<p>{{ message.text }}</p>
</td></tr>
{% endfor %}
......
......@@ -3,35 +3,65 @@ from django.apps import apps
from django.conf import settings
from django.utils.html import format_html, mark_safe, conditional_escape
from django.templatetags.static import static
from django.template.defaultfilters import date
from fontawesome_6.app_settings import get_css
from AKModel.models import Event
register = template.Library()
# Get Footer Info from settings
@register.simple_tag
def footer_info():
"""
Get Footer Info from settings
:return: a dict of several strings like the impress URL to use in the footer
:rtype: Dict[str, str]
"""
return settings.FOOTER_INFO
@register.filter
def check_app_installed(name):
"""
Check whether the app with the given name is active in this instance
:param name: name of the app to check for
:return: true if app is installed
:rtype: bool
"""
return apps.is_installed(name)
@register.filter
def message_bootstrap_class(tag):
"""
Turn message severity classes into corresponding bootstrap css classes
:param tag: severity of the message
:return: matching bootstrap class
"""
if tag == "error":
return "alert-danger"
elif tag == "success":
if tag == "success":
return "alert-success"
elif tag == "warning":
if tag == "warning":
return "alert-warning"
return "alert-info"
@register.filter
def wiki_owners_export(owners, event):
"""
Preserve owner link information for wiki export by using internal links if possible
but external links when owner specified a non-wikilink. This is applied to the full list of owners
:param owners: list of owners
:param event: event this owner belongs to and that is currently exported (specifying this directly prevents unnecessary database lookups) #pylint: disable=line-too-long
:return: linkified owners list in wiki syntax
:rtype: str
"""
def to_link(owner):
if owner.link != '':
event_link_prefix, _ = event.base_url.rsplit("/", 1)
......@@ -44,17 +74,45 @@ def wiki_owners_export(owners, event):
return ", ".join(to_link(owner) for owner in owners.all())
@register.filter
def event_month_year(event:Event):
"""
Print rough event date (month and year)
:param event: event to print the date for
:return: string containing rough date information for event
"""
if event.start.month == event.end.month:
return f"{date(event.start, 'F')} {event.start.year}"
event_start_string = date(event.start, 'F')
if event.start.year != event.end.year:
event_start_string = f"{event_start_string} {event.start.year}"
return f"{event_start_string} - {date(event.end, 'F')} {event.end.year}"
# get list of relevant css fontawesome css files for this instance
css = get_css()
@register.simple_tag
def fontawesome_6_css():
"""
Create html code to load all required fontawesome css files
:return: HTML code to load css
:rtype: str
"""
return mark_safe(conditional_escape('\n').join(format_html(
'<link href="{}" rel="stylesheet" media="all">', stylesheet) for stylesheet in css))
@register.simple_tag
def fontawesome_6_js():
"""
Create html code to load all required fontawesome javascript files
:return: HTML code to load js
:rtype: str
"""
return mark_safe(format_html(
'<script type="text/javascript" src="{}"></script>', static('fontawesome_6/js/django-fontawesome.js')
))
\ No newline at end of file
))
import traceback
from typing import List
from django.contrib.auth.models import User
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.contrib.messages.storage.base import Message
from django.test import TestCase
from django.urls import reverse_lazy, reverse
from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \
ConstraintViolation, DefaultSlot
from django.urls import reverse, reverse_lazy
from AKModel.models import (
AK,
AKCategory,
AKOrgaMessage,
AKOwner,
AKRequirement,
AKSlot,
AKTrack,
ConstraintViolation,
DefaultSlot,
Event,
Room,
)
class BasicViewTests:
"""
Parent class for "standard" tests of views
Provided with a list of views and arguments (if necessary), this will test that views
- render correctly without errors
- are only reachable with the correct rights (neither too freely nor too restricted)
To do this, the test creates sample users, fixtures are loaded automatically by the django test framework.
It also provides helper functions, e.g., to check for correct messages to the user or more simply generate
the URLs to test
In this class, methods from :class:`TestCase` will be called at multiple places event though TestCase is not a
parent of this class but has to be included as parent in concrete implementations of this class seperately.
It however still makes sense to treat this class as some kind of mixin and not implement it as a child of TestCase,
since the test framework does not understand the concept of abstract test definitions and would handle this class
as real test case otherwise, distorting the test results.
"""
# pylint: disable=no-member
VIEWS = []
APP_NAME = ''
APP_NAME = ""
VIEWS_STAFF_ONLY = []
EDIT_TESTCASES = []
def setUp(self):
self.staff_user = User.objects.create(
username='Test Staff User', email='teststaff@example.com', password='staffpw',
is_staff=True, is_active=True
def setUp(self): # pylint: disable=invalid-name
"""
Setup testing by creating sample users
"""
user_model = get_user_model()
self.staff_user = user_model.objects.create(
username="Test Staff User",
email="teststaff@example.com",
password="staffpw",
is_staff=True,
is_active=True,
)
self.admin_user = User.objects.create(
username='Test Admin User', email='testadmin@example.com', password='adminpw',
is_staff=True, is_superuser=True, is_active=True
self.admin_user = user_model.objects.create(
username="Test Admin User",
email="testadmin@example.com",
password="adminpw",
is_staff=True,
is_superuser=True,
is_active=True,
)
self.deactivated_user = User.objects.create(
username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw',
is_staff=True, is_active=False
self.deactivated_user = user_model.objects.create(
username="Test Deactivated User",
email="testdeactivated@example.com",
password="deactivatedpw",
is_staff=True,
is_active=False,
)
def _name_and_url(self, view_name):
......@@ -40,12 +84,21 @@ class BasicViewTests:
:return: full view name with prefix if applicable, url of the view
:rtype: str, str
"""
view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
view_name_with_prefix = (
f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
)
url = reverse(view_name_with_prefix, kwargs=view_name[1])
return view_name_with_prefix, url
def _assert_message(self, response, expected_message, msg_prefix=""):
messages:List[Message] = list(get_messages(response.wsgi_request))
"""
Assert that the correct message is shown and cause test to fail if not
:param response: response to check
:param expected_message: message that should be shown
:param msg_prefix: prefix for the error message when test fails
"""
messages: List[Message] = list(get_messages(response.wsgi_request))
msg_count = "No message shown to user"
msg_content = "Wrong message, expected '{expected_message}'"
......@@ -59,60 +112,104 @@ class BasicViewTests:
self.assertEqual(messages[-1].message, expected_message, msg=msg_content)
def test_views_for_200(self):
"""
Test the list of public views (as specified in "VIEWS") for error-free rendering
"""
for view_name in self.VIEWS:
view_name_with_prefix, url = self._name_and_url(view_name)
try:
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken")
except Exception as e:
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}")
self.assertEqual(
response.status_code,
200,
msg=f"{view_name_with_prefix} ({url}) broken",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
def test_access_control_staff_only(self):
"""
Test whether internal views (as specified in "VIEWS_STAFF_ONLY" are visible to staff users and staff users only
"""
# Not logged in? Views should not be visible
self.client.logout()
for view_name in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name)
for view_name_info in self.VIEWS_STAFF_ONLY:
expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url)
self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) accessible by non-staff")
self.assertEqual(
response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) accessible by non-staff",
)
# Logged in? Views should be visible
self.client.force_login(self.staff_user)
for view_name in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name)
for view_name_info in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name_info)
try:
response = self.client.get(url)
self.assertEqual(response.status_code, 200,
msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)")
except Exception as e:
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}")
self.assertEqual(
response.status_code,
200,
msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
# Disabled user? Views should not be visible
self.client.force_login(self.deactivated_user)
for view_name in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name)
for view_name_info in self.VIEWS_STAFF_ONLY:
expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url)
self.assertEqual(response.status_code, 302,
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user")
self.assertEqual(
response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user",
)
def _to_sendable_value(self, v):
def _to_sendable_value(self, val):
"""
Create representation sendable via POST from form data
:param v: value to prepare
:type v: any
Needed to automatically check create, update and delete views
:param val: value to prepare
:type val: any
:return: prepared value (normally either raw value or primary key of complex object)
"""
if type(v) == list:
return [e.pk for e in v]
if type(v) == "RelatedManager":
return [e.pk for e in v.all()]
return v
if isinstance(val, list):
return [e.pk for e in val]
if type(val) == "RelatedManager": # pylint: disable=unidiomatic-typecheck
return [e.pk for e in val.all()]
return val
def test_submit_edit_form(self):
"""
Test edit forms in the most simple way (sending them again unchanged)
Test edit forms (as specified in "EDIT_TESTCASES") in the most simple way (sending them again unchanged)
"""
for testcase in self.EDIT_TESTCASES:
self._test_submit_edit_form(testcase)
def _test_submit_edit_form(self, testcase):
"""
Test a single edit form by rendering and sending it again unchanged
This will test for correct rendering, dispatching/redirecting, messages and access control handling
:param testcase: details of the form to test
"""
name, url = self._name_and_url((testcase["view"], testcase["kwargs"]))
form_name = testcase.get("form_name", "form")
expected_code = testcase.get("expected_code", 302)
......@@ -130,79 +227,135 @@ class BasicViewTests:
self.client.logout()
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{name}: Could not load edit form via GET ({url})")
self.assertEqual(
response.status_code,
200,
msg=f"{name}: Could not load edit form via GET ({url})",
)
form = response.context[form_name]
data = {k:self._to_sendable_value(v) for k,v in form.initial.items()}
data = {k: self._to_sendable_value(v) for k, v in form.initial.items()}
response = self.client.post(url, data=data)
if expected_code == 200:
self.assertEqual(response.status_code, 200, msg=f"{name}: Did not return 200 ({url}")
self.assertEqual(
response.status_code, 200, msg=f"{name}: Did not return 200 ({url}"
)
elif expected_code == 302:
self.assertRedirects(response, target_url, msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}")
self.assertRedirects(
response,
target_url,
msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}",
)
if expected_message != "":
self._assert_message(response, expected_message, msg_prefix=f"{name}")
class ModelViewTests(BasicViewTests, TestCase):
fixtures = ['model.json']
"""
Basic view test cases for views from AKModel plus some custom tests
"""
fixtures = ["model.json"]
ADMIN_MODELS = [
(Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'),
(AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'),
(AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'),
(DefaultSlot, 'defaultslot')
(Event, "event"),
(AKOwner, "akowner"),
(AKCategory, "akcategory"),
(AKTrack, "aktrack"),
(AKRequirement, "akrequirement"),
(AK, "ak"),
(Room, "room"),
(AKSlot, "akslot"),
(AKOrgaMessage, "akorgamessage"),
(ConstraintViolation, "constraintviolation"),
(DefaultSlot, "defaultslot"),
]
VIEWS_STAFF_ONLY = [
('admin:index', {}),
('admin:event_status', {'event_slug': 'kif42'}),
('admin:event_requirement_overview', {'event_slug': 'kif42'}),
('admin:ak_csv_export', {'event_slug': 'kif42'}),
('admin:ak_wiki_export', {'slug': 'kif42'}),
('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}),
('admin:ak_slide_export', {'event_slug': 'kif42'}),
('admin:default-slots-editor', {'event_slug': 'kif42'}),
('admin:room-import', {'event_slug': 'kif42'}),
('admin:new_event_wizard_start', {}),
("admin:index", {}),
("admin:event_status", {"event_slug": "kif42"}),
("admin:event_requirement_overview", {"event_slug": "kif42"}),
("admin:ak_csv_export", {"event_slug": "kif42"}),
("admin:ak_wiki_export", {"slug": "kif42"}),
("admin:ak_delete_orga_messages", {"event_slug": "kif42"}),
("admin:ak_slide_export", {"event_slug": "kif42"}),
("admin:default-slots-editor", {"event_slug": "kif42"}),
("admin:room-import", {"event_slug": "kif42"}),
("admin:new_event_wizard_start", {}),
]
EDIT_TESTCASES = [
{'view': 'admin:default-slots-editor', 'kwargs': {'event_slug': 'kif42'}, "admin": True},
{
"view": "admin:default-slots-editor",
"kwargs": {"event_slug": "kif42"},
"admin": True,
},
]
def test_admin(self):
"""
Test basic admin functionality (displaying and interacting with model instances)
"""
self.client.force_login(self.admin_user)
for model in self.ADMIN_MODELS:
# Special treatment for a subset of views (where we exchanged default functionality, e.g., create views)
if model[1] == "event":
view_name_with_prefix, url = self._name_and_url((f'admin:new_event_wizard_start', {}))
_, url = self._name_and_url(("admin:new_event_wizard_start", {}))
elif model[1] == "room":
view_name_with_prefix, url = self._name_and_url((f'admin:room-new', {}))
_, url = self._name_and_url(("admin:room-new", {}))
# Otherwise, just call the creation form view
else:
view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {}))
_, url = self._name_and_url((f"admin:AKModel_{model[1]}_add", {}))
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken")
self.assertEqual(
response.status_code,
200,
msg=f"Add form for model {model[1]} ({url}) broken",
)
for model in self.ADMIN_MODELS:
# Test the update view using the first existing instance of each model
m = model[0].objects.first()
if m is not None:
view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_change', {'object_id': m.pk}))
_, url = self._name_and_url(
(f"admin:AKModel_{model[1]}_change", {"object_id": m.pk})
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken")
self.assertEqual(
response.status_code,
200,
msg=f"Edit form for model {model[1]} ({url}) broken",
)
def test_wiki_export(self):
"""
Test wiki export
This will test whether the view renders at all and whether the export list contains the correct AKs
"""
self.client.force_login(self.admin_user)
export_url = reverse_lazy(f"admin:ak_wiki_export", kwargs={'slug': 'kif42'})
export_url = reverse_lazy("admin:ak_wiki_export", kwargs={"slug": "kif42"})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200, "Export not working at all")
export_count = 0
for category, aks in response.context["categories_with_aks"]:
for _, aks in response.context["categories_with_aks"]:
for ak in aks:
self.assertEqual(ak.include_in_export, True, f"AK with export flag set to False (pk={ak.pk}) included in export")
self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export")
self.assertEqual(
ak.include_in_export,
True,
f"AK with export flag set to False (pk={ak.pk}) included in export",
)
self.assertNotEqual(
ak.pk,
1,
"AK known to be excluded from export (PK 1) included in export",
)
export_count += 1
self.assertEqual(export_count, AK.objects.filter(event_id=2, include_in_export=True).count(),
"Wiki export contained the wrong number of AKs")
self.assertEqual(
export_count,
AK.objects.filter(event_id=2, include_in_export=True).count(),
"Wiki export contained the wrong number of AKs",
)
......@@ -4,12 +4,16 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter
import AKModel.views.api
from AKModel.views.manage import ExportSlidesView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView
from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, \
PollPublishView, PollUnpublishView, DefaultSlotEditorView, AKsByUserView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, \
AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView
from AKModel.views.room import RoomBatchCreationView
from AKModel.views.status import EventStatusView
# Register basic API views/endpoints
api_router = DefaultRouter()
api_router.register('akowner', AKModel.views.api.AKOwnerViewSet, basename='AKOwner')
api_router.register('akcategory', AKModel.views.api.AKCategoryViewSet, basename='AKCategory')
......@@ -18,7 +22,9 @@ api_router.register('ak', AKModel.views.api.AKViewSet, basename='AK')
api_router.register('room', AKModel.views.api.RoomViewSet, basename='Room')
api_router.register('akslot', AKModel.views.api.AKSlotViewSet, basename='AKSlot')
# TODO Can we move this functionality to the individual apps instead?
extra_paths = []
# If AKScheduling is active, register additional API endpoints
if apps.is_installed("AKScheduling"):
from AKScheduling.api import ResourcesViewSet, RoomAvailabilitiesView, EventsView, EventsViewSet, \
ConstraintViolationsViewSet, DefaultSlotsView
......@@ -33,11 +39,17 @@ if apps.is_installed("AKScheduling"):
name='scheduling-room-availabilities')),
extra_paths.append(path('api/scheduling-default-slots/', DefaultSlotsView.as_view(),
name='scheduling-default-slots'))
#If AKSubmission is active, register an additional API endpoint for increasing the interest counter
if apps.is_installed("AKSubmission"):
from AKSubmission.api import increment_interest_counter
extra_paths.append(path('api/ak/<pk>/indicate-interest/', increment_interest_counter, name='submission-ak-indicate-interest'))
# If AKSolverInterface is active, register additional API endpoints
if apps.is_installed("AKSolverInterface"):
from AKSolverInterface.api import ExportEventForSolverViewSet
api_router.register("solver-export", ExportEventForSolverViewSet, basename="solver-export")
event_specific_paths = [
path('api/', include(api_router.urls), name='api'),
]
......@@ -45,6 +57,7 @@ event_specific_paths.extend(extra_paths)
app_name = 'model'
# Included all these extra view paths at a path starting with the event slug
urlpatterns = [
path(
'<slug:event_slug>/',
......@@ -55,6 +68,9 @@ urlpatterns = [
def get_admin_urls_event_wizard(admin_site):
"""
Defines all additional URLs for the event creation wizard
"""
return [
path('add/wizard/start/', admin_site.admin_view(NewEventWizardStartView.as_view()),
name="new_event_wizard_start"),
......@@ -75,10 +91,15 @@ def get_admin_urls_event_wizard(admin_site):
def get_admin_urls_event(admin_site):
"""
Defines all additional event-related view URLs that will be included in the event admin interface
"""
return [
path('<slug:event_slug>/status/', admin_site.admin_view(EventStatusView.as_view()), name="event_status"),
path('<slug:event_slug>/requirements/', admin_site.admin_view(AKRequirementOverview.as_view()),
name="event_requirement_overview"),
path('<slug:event_slug>/aks/owner/<pk>/', admin_site.admin_view(AKsByUserView.as_view()),
name="aks_by_owner"),
path('<slug:event_slug>/ak-csv-export/', admin_site.admin_view(AKCSVExportView.as_view()),
name="ak_csv_export"),
path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()),
......@@ -86,4 +107,12 @@ def get_admin_urls_event(admin_site):
path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()),
name="ak_delete_orga_messages"),
path('<slug:event_slug>/ak-slide-export/', admin_site.admin_view(ExportSlidesView.as_view()), name="ak_slide_export"),
path('plan/publish/', admin_site.admin_view(PlanPublishView.as_view()), name="plan-publish"),
path('plan/unpublish/', admin_site.admin_view(PlanUnpublishView.as_view()), name="plan-unpublish"),
path('poll/publish/', admin_site.admin_view(PollPublishView.as_view()), name="poll-publish"),
path('poll/unpublish/', admin_site.admin_view(PollUnpublishView.as_view()), name="poll-unpublish"),
path('<slug:event_slug>/defaultSlots/', admin_site.admin_view(DefaultSlotEditorView.as_view()),
name="default-slots-editor"),
path('<slug:event_slug>/importRooms/', admin_site.admin_view(RoomBatchCreationView.as_view()),
name="room-import"),
]
......@@ -9,6 +9,9 @@ from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK
class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Display requirements for the given event
"""
model = AKRequirement
context_object_name = "requirements"
title = _("Requirements for Event")
......@@ -22,6 +25,9 @@ class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Export all AK slots of this event in CSV format ordered by tracks
"""
template_name = "admin/AKModel/ak_csv_export.html"
model = AKSlot
context_object_name = "slots"
......@@ -30,12 +36,12 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
def get_queryset(self):
return super().get_queryset().order_by("ak__track")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
return context
class AKWikiExportView(AdminViewMixin, DetailView):
"""
View: Export AKs of this event in wiki syntax
This will show one text field per category, with a separate category/field for wishes
"""
template_name = "admin/AKModel/wiki_export.html"
model = Event
context_object_name = "event"
......@@ -46,7 +52,7 @@ class AKWikiExportView(AdminViewMixin, DetailView):
categories_with_aks, ak_wishes = context["event"].get_categories_with_aks(
wishes_seperately=True,
filter=lambda ak: ak.include_in_export
filter_func=lambda ak: ak.include_in_export
)
context["categories_with_aks"] = [(category.name, ak_list) for category, ak_list in categories_with_aks]
......@@ -56,10 +62,18 @@ class AKWikiExportView(AdminViewMixin, DetailView):
class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView):
"""
View: Confirmation page to delete confidential AK-related messages to orga
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
template_name = "admin/AKModel/message_delete.html"
title = _("Delete AK Orga Messages")
def get_orga_messages_for_event(self, event):
"""
Get all orga messages for the given event
"""
return AKOrgaMessage.objects.filter(ak__event=event)
def get_success_url(self):
......@@ -77,6 +91,11 @@ class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView):
class AKResetInterestView(IntermediateAdminActionView):
"""
View: Confirmation page to reset all manually specified interest values
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
title = _("Reset interest in AKs")
model = AK
confirmation_message = _("Interest of the following AKs will be set to not filled (-1):")
......@@ -87,6 +106,11 @@ class AKResetInterestView(IntermediateAdminActionView):
class AKResetInterestCounterView(IntermediateAdminActionView):
"""
View: Confirmation page to reset all interest counters (online interest indication)
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
title = _("Reset AKs' interest counters")
model = AK
confirmation_message = _("Interest counter of the following AKs will be set to 0:")
......
......@@ -7,6 +7,10 @@ from AKModel.serializers import AKOwnerSerializer, AKCategorySerializer, AKTrack
class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Owners (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKOwnerSerializer
......@@ -15,6 +19,10 @@ class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModel
class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Categories (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKCategorySerializer
......@@ -24,6 +32,10 @@ class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListMo
class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Tracks (restricted to those of the given event)
Read, Write, Delete
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKTrackSerializer
......@@ -33,6 +45,10 @@ class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateMod
class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet):
"""
API View: AKs (restricted to those of the given event)
Read, Write
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKSerializer
......@@ -41,6 +57,10 @@ class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMix
class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Rooms (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = RoomSerializer
......@@ -50,6 +70,10 @@ class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMix
class AKSlotViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin,
mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: AK slots (restricted to those of the given event)
Read, Write
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKSlotSerializer
......
......@@ -12,6 +12,12 @@ from AKModel.models import Event
class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView):
"""
Wizard view: Entry/Start
Specify basic settings, especially the timezone for correct time treatment in the next view
(:class:`NewEventWizardSettingsView`) where this view will redirect to without saving the new event already
"""
model = Event
form_class = NewEventWizardStartForm
template_name = "admin/AKModel/event_wizard/start.html"
......@@ -19,6 +25,16 @@ class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView):
class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView):
"""
Wizard view: Event settings
Specify most of the event settings. The user will see that certain fields are required since they were lead here
from another form in :class:`NewEventWizardStartView` that did not contain these fields even though they are
mandatory for the event model
Next step will then be :class:`NewEventWizardPrepareImportView` to prepare copy configuration elements
from an existing event
"""
model = Event
form_class = NewEventWizardSettingsForm
template_name = "admin/AKModel/event_wizard/settings.html"
......@@ -34,6 +50,14 @@ class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView):
class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView):
"""
Wizard view: Choose event to copy configuration elements from
The user can here select an existing event to copy elements like requirements, categories and dashboard buttons from
The exact subset of elements to copy from can then be selected in the next view (:class:`NewEventWizardImportView`)
Instead, this step can be skipped by directly continuing with :class:`NewEventWizardActivateView`
"""
form_class = NewEventWizardPrepareImportForm
template_name = "admin/AKModel/event_wizard/created_prepare_import.html"
wizard_step = 3
......@@ -45,29 +69,40 @@ class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView)
class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView):
"""
Wizard view: Select configuration elements to copy
Displays lists of requirements, categories and dashboard buttons that the user can select entries to be copied from
Afterwards, the event can be activated in :class:`NewEventWizardActivateView`
"""
form_class = NewEventWizardImportForm
template_name = "admin/AKModel/event_wizard/import.html"
wizard_step = 4
def get_initial(self):
initial = super().get_initial()
# Remember which event was selected and send it again when submitting the form for validation
initial["import_event"] = Event.objects.get(slug=self.kwargs["import_slug"])
return initial
def form_valid(self, form):
# pylint: disable=consider-using-f-string
import_types = ["import_categories", "import_requirements"]
if apps.is_installed("AKDashboard"):
import_types.append("import_buttons")
# Loop over all kinds of configuration elements and then over all selected elements of each type
# and try to clone them by requesting a new primary key, adapting the event and then storing the
# object in the database
for import_type in import_types:
for import_obj in form.cleaned_data.get(import_type):
# clone existing entry
try:
import_obj.event = self.event
import_obj.pk = None
import_obj.save()
messages.add_message(self.request, messages.SUCCESS, _("Copied '%(obj)s'" % {'obj': import_obj}))
except BaseException as e:
except BaseException as e: # pylint: disable=broad-exception-caught
messages.add_message(self.request, messages.ERROR,
_("Could not copy '%(obj)s' (%(error)s)" % {'obj': import_obj,
"error": str(e)}))
......@@ -75,6 +110,17 @@ class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView):
class NewEventWizardActivateView(WizardViewMixin, UpdateView):
"""
Wizard view: Allow activating the event
The user is asked to make the created event active. This is done in this step and not already during the creation
in the second step of the wizard to prevent users seeing an unconfigured submission.
The event will nevertheless already be visible in the dashboard before, when a public event was created in
:class:`NewEventWizardSettingsView`.
In the following last step (:class:`NewEventWizardFinishView`), a confirmation of the full process and some
details of the created event are shown
"""
model = Event
template_name = "admin/AKModel/event_wizard/activate.html"
form_class = NewEventWizardActivateForm
......@@ -85,6 +131,11 @@ class NewEventWizardActivateView(WizardViewMixin, UpdateView):
class NewEventWizardFinishView(WizardViewMixin, DetailView):
"""
Wizard view: Confirmation and summary
Show a confirmation and a summary of the created event
"""
model = Event
template_name = "admin/AKModel/event_wizard/finish.html"
wizard_step = 6
......@@ -8,26 +8,51 @@ from django.contrib import messages
from django.db.models.functions import Now
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView
from django.views.generic import DetailView, ListView, TemplateView
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
from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView
from AKModel.models import ConstraintViolation, Event, DefaultSlot
from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin
from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner, AKSlot, AKType
class UserView(TemplateView):
"""
View: Start page for logged in user
Will over a link to backend or inform the user that their account still needs to be confirmed
"""
template_name = "AKModel/user.html"
class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
"""
View: Export slides to present AKs
Over a form to choose some settings for the export and then generate the PDF
"""
title = _('Export AK Slides')
form_class = SlideExportForm
def get_form(self, form_class=None):
# Filter type choices to those of the current event
# or completely hide the field if no types are specified for this event
form = super().get_form(form_class)
if self.event.aktype_set.count() > 0:
form.fields['types'].choices = [
(ak_type.id, ak_type.name) for ak_type in self.event.aktype_set.all()
]
else:
form.fields['types'].widget = form.fields['types'].hidden_widget()
form.fields['types_all_selected_only'].widget = form.fields['types_all_selected_only'].hidden_widget()
return form
def form_valid(self, form):
# pylint: disable=invalid-name
template_name = 'admin/AKModel/export/slides.tex'
# Settings
NEXT_AK_LIST_LENGTH = form.cleaned_data['num_next']
RESULT_PRESENTATION_MODE = form.cleaned_data["presentation_mode"]
SPACE_FOR_NOTES_IN_WISHES = form.cleaned_data["wish_notes"]
......@@ -39,24 +64,44 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
'reso': _("Reso intention?"),
'category': _("Category (for Wishes)"),
'wishes': _("Wishes"),
'types': _("Types"),
}
def build_ak_list_with_next_aks(ak_list):
"""
Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting)
"""
next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None)
return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=list())]
categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter=lambda
ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default)))
return list(zip_longest(ak_list, next_aks_list, fillvalue=[]))
# Create a list of types to filter AKs by (if at least one type was selected)
types = None
types_filter_string = ""
show_types = self.event.aktype_set.count() > 0
if len(form.cleaned_data['types']) > 0:
types = AKType.objects.filter(id__in=form.cleaned_data['types'])
names_string = ', '.join(AKType.objects.get(pk=t).name for t in form.cleaned_data['types'])
types_filter_string = f"[{_('Type(s)')}: {names_string}]"
types_all_selected_only = form.cleaned_data['types_all_selected_only']
# Get all relevant AKs (wishes separately, and either all AKs or only those who should directly or indirectly
# be presented when restriction setting was chosen)
categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter_func=lambda
ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default)),
types=types,
types_all_selected_only=types_all_selected_only)
# Create context for LaTeX rendering
context = {
'title': self.event.name,
'categories_with_aks': [(category, build_ak_list_with_next_aks(ak_list)) for category, ak_list in
categories_with_aks],
'subtitle': _("AKs"),
'subtitle': _("AKs") + " " + types_filter_string,
"wishes": build_ak_list_with_next_aks(ak_wishes),
"translations": translations,
"result_presentation_mode": RESULT_PRESENTATION_MODE,
"space_for_notes_in_wishes": SPACE_FOR_NOTES_IN_WISHES,
"show_types": show_types,
}
source = render_template_with_context(template_name, context)
......@@ -67,11 +112,17 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
os.remove(f'{tempdir}/texput.tex')
pdf = run_tex_in_directory(source, tempdir, template_name=self.template_name)
# Show PDF file to the user (with a filename containing a timestamp to prevent confusions about the right
# version to use when generating multiple versions of the slides, e.g., because owners did last-minute changes
# to their AKs
timestamp = datetime.datetime.now(tz=self.event.timezone).strftime("%Y-%m-%d_%H_%M")
return PDFResponse(pdf, filename=f'{self.event.slug}_ak_slides_{timestamp}.pdf')
class CVMarkResolvedView(IntermediateAdminActionView):
"""
Admin action view: Mark one or multitple constraint violation(s) as resolved
"""
title = _('Mark Constraint Violations as manually resolved')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be marked as manually resolved")
......@@ -82,6 +133,9 @@ class CVMarkResolvedView(IntermediateAdminActionView):
class CVSetLevelViolationView(IntermediateAdminActionView):
"""
Admin action view: Set one or multitple constraint violation(s) as to level "violation"
"""
title = _('Set Constraint Violations to level "violation"')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'violation'")
......@@ -92,6 +146,9 @@ class CVSetLevelViolationView(IntermediateAdminActionView):
class CVSetLevelWarningView(IntermediateAdminActionView):
"""
Admin action view: Set one or multitple constraint violation(s) as to level "warning"
"""
title = _('Set Constraint Violations to level "warning"')
model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'warning'")
......@@ -100,8 +157,33 @@ class CVSetLevelWarningView(IntermediateAdminActionView):
def action(self, form):
self.entities.update(level=ConstraintViolation.ViolationLevel.WARNING)
class ClearScheduleView(IntermediateAdminActionView, ListView):
"""
Admin action view: Clear schedule
"""
title = _('Clear schedule')
model = AKSlot
confirmation_message = _('Clear schedule. The following scheduled AKSlots will be reset')
success_message = _('Schedule cleared')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.entities = AKSlot.objects.none()
def get_queryset(self, *args, **kwargs):
query_set = super().get_queryset(*args, **kwargs)
# do not reset fixed AKs
query_set = query_set.filter(fixed=False)
return query_set
def action(self, form):
"""Reset rooms and start for all selected slots."""
self.entities.update(room=None, start=None)
class PlanPublishView(IntermediateAdminActionView):
"""
Admin action view: Publish the plan of one or multitple event(s)
"""
title = _('Publish plan')
model = Event
confirmation_message = _('Publish the plan(s) of:')
......@@ -112,6 +194,9 @@ class PlanPublishView(IntermediateAdminActionView):
class PlanUnpublishView(IntermediateAdminActionView):
"""
Admin action view: Unpublish the plan of one or multitple event(s)
"""
title = _('Unpublish plan')
model = Event
confirmation_message = _('Unpublish the plan(s) of:')
......@@ -121,7 +206,35 @@ class PlanUnpublishView(IntermediateAdminActionView):
self.entities.update(plan_published_at=None, plan_hidden=True)
class PollPublishView(IntermediateAdminActionView):
"""
Admin action view: Publish the preference poll of one or multitple event(s)
"""
title = _('Publish preference poll')
model = Event
confirmation_message = _('Publish the poll(s) of:')
success_message = _('Preference poll published')
def action(self, form):
self.entities.update(poll_published_at=Now(), poll_hidden=False)
class PollUnpublishView(IntermediateAdminActionView):
"""
Admin action view: Unpublish the preference poll of one or multitple event(s)
"""
title = _('Unpublish preference poll')
model = Event
confirmation_message = _('Unpublish the preference poll(s) of:')
success_message = _('Preference poll unpublished')
def action(self, form):
self.entities.update(poll_published_at=None, poll_hidden=True)
class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
"""
Admin view: Allow to edit the default slots of an event
"""
template_name = "admin/AKModel/default_slot_editor.html"
form_class = DefaultSlotEditorForm
title = _("Edit Default Slots")
......@@ -149,13 +262,14 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
previous_slot_ids = set(s.id for s in self.event.defaultslot_set.all())
# Loop over inputs and update or add slots
for slot in default_slots_raw:
start = parse_datetime(slot["start"]).astimezone(tz)
end = parse_datetime(slot["end"]).astimezone(tz)
start = parse_datetime(slot["start"]).replace(tzinfo=tz)
end = parse_datetime(slot["end"]).replace(tzinfo=tz)
if slot["id"] != '':
id = int(slot["id"])
if id not in previous_slot_ids:
slot_id = int(slot["id"])
if slot_id not in previous_slot_ids:
# Make sure only slots (currently) belonging to this event are edited
# (user did not manipulate IDs and slots have not been deleted in another session in the meantime)
messages.add_message(
......@@ -166,8 +280,8 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
)
else:
# Update existing entries
previous_slot_ids.remove(id)
original_slot = DefaultSlot.objects.get(id=id)
previous_slot_ids.remove(slot_id)
original_slot = DefaultSlot.objects.get(id=slot_id)
if original_slot.start != start or original_slot.end != end:
original_slot.start = start
original_slot.end = end
......@@ -187,6 +301,7 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
for d_id in previous_slot_ids:
DefaultSlot.objects.get(id=d_id).delete()
# Inform user about changes performed
if created_count + updated_count + deleted_count > 0:
messages.add_message(
self.request,
......@@ -195,3 +310,12 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
.format(u=str(updated_count), c=str(created_count), d=str(deleted_count))
)
return super().form_valid(form)
class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView):
"""
View: Show all AKs of a given user
"""
model = AKOwner
context_object_name = 'owner'
template_name = "admin/AKModel/aks_by_user.html"