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
  • komasolver
  • main
  • renovate/django_csp-4.x
  • renovate/jsonschema-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
  • 520-akowner
  • 520-fix-event-wizard-datepicker
  • 520-fix-scheduling
  • 520-improve-scheduling
  • 520-improve-scheduling-2
  • 520-improve-submission
  • 520-improve-trackmanager
  • 520-improve-wall
  • 520-message-resolved
  • 520-status
  • 520-upgrades
  • add_express_interest_to_ak_overview
  • admin-production-color
  • bugfixes
  • csp
  • featire-ical-export
  • feature-ak-requirement-lists
  • feature-akslide-export-better-filename
  • feature-akslides
  • feature-better-admin
  • feature-better-cv-list
  • feature-colors
  • feature-constraint-checking
  • feature-constraint-checking-wip
  • feature-dashboard-history-button
  • feature-event-status
  • feature-event-wizard
  • feature-export-flag
  • feature-improve-admin
  • feature-improve-filters
  • feature-improved-user-creation-workflow
  • feature-interest-view
  • feature-mails
  • feature-modular-status
  • feature-plan-autoreload
  • feature-present-default
  • feature-register-link
  • feature-remaining-constraint-validation
  • feature-room-import
  • feature-scheduler-improve
  • feature-scheduling-2.0
  • feature-special-attention
  • feature-time-input
  • feature-tracker
  • feature-wiki-wishes
  • feature-wish-slots
  • feature-wizard-buttons
  • features-availabilities
  • fix-ak-times-above-folg
  • fix-api
  • fix-constraint-violation-string
  • fix-cv-checking
  • fix-default-slot-length
  • fix-default-slot-localization
  • fix-doc-minor
  • fix-duration-display
  • fix-event-tz-pytz-update
  • fix-history-interest
  • fix-interest-view
  • fix-js
  • fix-pipeline
  • fix-plan-timezone-now
  • fix-room-add
  • fix-scheduling-drag
  • fix-slot-defaultlength
  • fix-timezone
  • fix-translation-scheduling
  • fix-virtual-room-admin
  • fix-wizard-csp
  • font-locally
  • improve-admin
  • improve-online
  • improve-slides
  • improve-submission-coupling
  • interest_restriction
  • main
  • master
  • meta-debug-toolbar
  • meta-export
  • meta-makemessages
  • meta-performance
  • meta-tests
  • meta-tests-gitlab-test
  • meta-upgrades
  • mollux-master-patch-02906
  • port-availabilites-fullcalendar
  • qs
  • remove-tags
  • renovate/configure
  • renovate/django-4.x
  • renovate/django-5.x
  • renovate/django-bootstrap-datepicker-plus-5.x
  • renovate/django-bootstrap5-23.x
  • renovate/django-bootstrap5-24.x
  • renovate/django-compressor-4.x
  • renovate/django-debug-toolbar-4.x
  • renovate/django-registration-redux-2.x
  • renovate/django-simple-history-3.x
  • renovate/django-split-settings-1.x
  • renovate/django-timezone-field-5.x
100 results
Show changes
Showing
with 1380 additions and 84 deletions
......@@ -13,36 +13,61 @@ from AKModel.models import Event
class EventSlugMixin:
"""
Mixin to handle views with event slugs
This will make the relevant event available as self.event in all kind types of requests
(generic GET and POST views, list views, dispatching, create views)
"""
# pylint: disable=no-member
event = None
def _load_event(self):
"""
Perform the real loading of the event data (as specified by event_slug in the URL) into self.event
"""
# Find event based on event slug
if self.event is None:
self.event = get_object_or_404(Event, slug=self.kwargs.get("event_slug", None))
def get(self, request, *args, **kwargs):
"""
Override GET request handling to perform loading event first
"""
self._load_event()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""
Override POST request handling to perform loading event first
"""
self._load_event()
return super().post(request, *args, **kwargs)
def list(self, request, *args, **kwargs):
"""
Override list view request handling to perform loading event first
"""
self._load_event()
return super().list(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
"""
Override create view request handling to perform loading event first
"""
self._load_event()
return super().create(request, *args, **kwargs)
def dispatch(self, request, *args, **kwargs):
"""
Override dispatch which is called in many generic views to perform loading event first
"""
if self.event is None:
self._load_event()
return super().dispatch(request, *args, **kwargs)
def get_context_data(self, *, object_list=None, **kwargs):
"""
Override `get_context_data` to make the event information available in the rendering context as `event`. too
"""
context = super().get_context_data(object_list=object_list, **kwargs)
# Add event to context (to make it accessible in templates)
context["event"] = self.event
......@@ -55,15 +80,29 @@ class FilterByEventSlugMixin(EventSlugMixin):
"""
def get_queryset(self):
# Filter current queryset based on url event slug or return 404 if event slug is invalid
"""
Get adapted queryset:
Filter current queryset based on url event slug or return 404 if event slug is invalid
:return: Queryset
"""
return super().get_queryset().filter(event=self.event)
class AdminViewMixin:
"""
Mixin to provide context information needed in custom admin views
Will either use default information for `site_url` and `title` or allows to set own values for that
"""
# pylint: disable=too-few-public-methods
site_url = ''
title = ''
def get_context_data(self, **kwargs):
"""
Extend context
"""
extra = admin.site.each_context(self.request)
extra.update(super().get_context_data(**kwargs))
......@@ -76,10 +115,19 @@ class AdminViewMixin:
class IntermediateAdminView(AdminViewMixin, FormView):
"""
Metaview: Handle typical "action but with preview and confirmation step before" workflow
"""
template_name = "admin/AKModel/action_intermediate.html"
form_class = AdminIntermediateForm
def get_preview(self):
"""
Render a preview of the action to be performed.
Default is empty
:return: preview (html)
:rtype: str
"""
return ""
def get_context_data(self, **kwargs):
......@@ -90,7 +138,18 @@ class IntermediateAdminView(AdminViewMixin, FormView):
class WizardViewMixin:
"""
Mixin to create wizard-like views.
This visualizes the progress of the user in the creation process and provides the interlinking to the next step
In the current implementation, the steps of the wizard are hardcoded here,
hence this mixin can only be used for the event creation wizard
"""
# pylint: disable=too-few-public-methods
def get_context_data(self, **kwargs):
"""
Extend context
"""
context = super().get_context_data(**kwargs)
context["wizard_step"] = self.wizard_step
context["wizard_steps"] = [
......@@ -107,10 +166,23 @@ class WizardViewMixin:
class IntermediateAdminActionView(IntermediateAdminView, ABC):
"""
Abstract base view: Intermediate action view (preview & confirmation see :class:`IntermediateAdminView`)
for custom admin actions (marking multiple objects in a django admin model instances list with a checkmark and then
choosing an action from the dropdown).
This will automatically handle the decoding of the URL encoding of the list of primary keys django does to select
which objects the action should be run on, then display a preview, perform the action after confirmation and
redirect again to the object list including display of a confirmation message
"""
# pylint: disable=no-member
form_class = AdminIntermediateActionForm
entities = None
def get_queryset(self, pks=None):
"""
Get the queryset of objects to perform the action on
"""
if pks is None:
pks = self.request.GET['pks']
return self.model.objects.filter(pk__in=pks.split(","))
......@@ -130,7 +202,10 @@ class IntermediateAdminActionView(IntermediateAdminView, ABC):
@abstractmethod
def action(self, form):
pass
"""
The real action to perform
:param form: form holding the data probably needed for the action
"""
def form_valid(self, form):
self.entities = self.get_queryset(pks=form.cleaned_data['pks'])
......@@ -140,7 +215,21 @@ class IntermediateAdminActionView(IntermediateAdminView, ABC):
class LoopActionMixin(ABC):
def action(self, form):
"""
Mixin for the typical kind of action where one needs to loop over all elements
and perform a certain function on each of them
The action is performed by overriding `perform_action(self, entity)`
further customization can be reached with the two callbacks `pre_action()` and `post_action()`
that are called before and after performing the action loop
"""
def action(self, form): # pylint: disable=unused-argument
"""
The real action to perform.
Will perform the loop, perform the action on each aelement and call the callbacks
:param form: form holding the data probably needed for the action
"""
self.pre_action()
for entity in self.entities:
self.perform_action(entity)
......@@ -149,10 +238,18 @@ class LoopActionMixin(ABC):
@abstractmethod
def perform_action(self, entity):
pass
"""
Action to perform on each entity
:param entity: entity to perform the action on
"""
def pre_action(self):
pass
"""
Callback for custom action before loop starts
"""
def post_action(self):
pass
"""
Callback for custom action after loop finished
"""
......@@ -8,6 +8,9 @@ from AKModel.metaviews.admin import AdminViewMixin
class StatusWidget(ABC):
"""
Abstract parent for status page widgets
"""
title = "Status Widget"
actions = []
status = "primary"
......@@ -18,7 +21,6 @@ class StatusWidget(ABC):
"""
Which model/context is needed to render this widget?
"""
pass
def get_context_data(self, context) -> dict:
"""
......@@ -32,6 +34,7 @@ class StatusWidget(ABC):
Render widget based on context
:param context: Context for rendering
:param request: HTTP request, needed for rendering
:return: Dictionary containing the rendered/prepared information
"""
context = self.get_context_data(context)
......@@ -42,7 +45,7 @@ class StatusWidget(ABC):
"status": self.render_status(context),
}
def render_title(self, context: {}) -> str:
def render_title(self, context: {}) -> str: # pylint: disable=unused-argument
"""
Render title for widget based on context
......@@ -52,7 +55,7 @@ class StatusWidget(ABC):
"""
return self.title
def render_status(self, context: {}) -> str:
def render_status(self, context: {}) -> str: # pylint: disable=unused-argument
"""
Render status for widget based on context
......@@ -63,16 +66,16 @@ class StatusWidget(ABC):
return self.status
@abstractmethod
def render_body(self, context: {}, request) -> str:
def render_body(self, context: {}, request) -> str: # pylint: disable=unused-argument
"""
Render body for widget based on context
:param context: Context for rendering
:param request: HTTP request (needed for rendering)
:return: Rendered widget body (HTML)
"""
pass
def render_actions(self, context: {}) -> list[dict]:
def render_actions(self, context: {}) -> list[dict]: # pylint: disable=unused-argument
"""
Render actions for widget based on context
......@@ -81,16 +84,30 @@ class StatusWidget(ABC):
:param context: Context for rendering
:return: List of actions
"""
return [a for a in self.actions]
return self.actions
class TemplateStatusWidget(StatusWidget):
"""
A :class:`StatusWidget` that produces its content by rendering a given html template
"""
@property
@abstractmethod
def template_name(self) -> str:
pass
"""
Configure the template to use
:return: name of the template to use
"""
def render_body(self, context: {}, request) -> str:
"""
Render the body of the widget using the template rendering method from django
(load and render template using the provided context)
:param context: context to use for rendering
:param request: HTTP request (needed by django)
:return: rendered content (HTML)
"""
template = loader.get_template(self.template_name)
return template.render(context, request)
......@@ -98,6 +115,8 @@ class TemplateStatusWidget(StatusWidget):
class StatusManager:
"""
Registry for all status widgets
Allows to register status widgets using the `@status_manager.register(name="xyz")` decorator
"""
widgets = {}
widgets_by_context_type = defaultdict(list)
......@@ -131,6 +150,9 @@ class StatusManager:
class StatusView(ABC, AdminViewMixin, TemplateView):
"""
Abstract view: A generic base view to create a status page holding multiple widgets
"""
template_name = "admin/AKModel/status/status.html"
@property
......@@ -139,12 +161,15 @@ class StatusView(ABC, AdminViewMixin, TemplateView):
"""
Which model/context is provided by this status view?
"""
pass
def get(self, request, *args, **kwargs):
context = self.get_context_data(**kwargs)
from AKModel.metaviews import status_manager
context['widgets'] = [w.render(context, self.request) for w in status_manager.get_by_context_type(self.provided_context_type)]
# Load status manager (local import to prevent cyclic import)
from AKModel.metaviews import status_manager # pylint: disable=import-outside-toplevel
# Render all widgets and provide them as part of the context
context['widgets'] = [w.render(context, self.request)
for w in status_manager.get_by_context_type(self.provided_context_type)]
return self.render_to_response(context)
# Generated by Django 4.2.11 on 2024-04-21 14:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('AKModel', '0058_alter_ak_options'),
]
operations = [
migrations.AlterField(
model_name='event',
name='interest_start',
field=models.DateTimeField(blank=True, help_text='Opening time for expression of interest. When left blank, no interest indication will be possible.', null=True, verbose_name='Interest Window Start'),
),
]
# Generated by Django 4.2.11 on 2024-04-24 21:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('AKModel', '0059_interest_default'),
]
operations = [
migrations.AddField(
model_name='akorgamessage',
name='resolved',
field=models.BooleanField(default=False, help_text='This message has been resolved (no further action needed)', verbose_name='Resolved'),
),
]
# Generated by Django 4.2.13 on 2025-02-25 20:58
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('AKModel', '0060_orga_message_resolved'),
]
operations = [
migrations.CreateModel(
name='AKType',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Name describing the type', max_length=128, verbose_name='Name')),
('event', models.ForeignKey(help_text='Associated event', on_delete=django.db.models.deletion.CASCADE, to='AKModel.event', verbose_name='Event')),
],
options={
'verbose_name': 'AK Type',
'verbose_name_plural': 'AK Types',
'ordering': ['name'],
'unique_together': {('event', 'name')},
},
),
migrations.AddField(
model_name='ak',
name='types',
field=models.ManyToManyField(blank=True, help_text='This AK is', to='AKModel.aktype', verbose_name='Types'),
),
]
# Generated by Django 4.2.13 on 2025-02-26 22:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('AKModel', '0061_types'),
]
operations = [
migrations.RemoveField(
model_name='historicalak',
name='interest',
),
]
# Generated by Django 4.2.13 on 2025-03-03 19:59
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('AKModel', '0062_interest_no_history'),
]
operations = [
migrations.AlterField(
model_name='ak',
name='name',
field=models.CharField(help_text='Name of the AK', max_length=256, validators=[django.core.validators.RegexValidator(inverse_match=True, message='May not contain quotation marks', regex='[\'\\"´`]+'), django.core.validators.RegexValidator(message='Must contain at least one letter or digit', regex='[\\w\\s]+')], verbose_name='Name'),
),
migrations.AlterField(
model_name='ak',
name='short_name',
field=models.CharField(blank=True, help_text='Name displayed in the schedule', max_length=64, validators=[django.core.validators.RegexValidator(inverse_match=True, message='May not contain quotation marks', regex='[\'\\"´`]+')], verbose_name='Short Name'),
),
migrations.AlterField(
model_name='akowner',
name='name',
field=models.CharField(help_text='Name to identify an AK owner by', max_length=64, validators=[django.core.validators.RegexValidator(inverse_match=True, message='May not contain quotation marks', regex='[\'\\"´`]+'), django.core.validators.RegexValidator(message='Must contain at least one letter or digit', regex='[\\w\\s]+')], verbose_name='Nickname'),
),
migrations.AlterField(
model_name='historicalak',
name='name',
field=models.CharField(help_text='Name of the AK', max_length=256, validators=[django.core.validators.RegexValidator(inverse_match=True, message='May not contain quotation marks', regex='[\'\\"´`]+'), django.core.validators.RegexValidator(message='Must contain at least one letter or digit', regex='[\\w\\s]+')], verbose_name='Name'),
),
migrations.AlterField(
model_name='historicalak',
name='short_name',
field=models.CharField(blank=True, help_text='Name displayed in the schedule', max_length=64, validators=[django.core.validators.RegexValidator(inverse_match=True, message='May not contain quotation marks', regex='[\'\\"´`]+')], verbose_name='Short Name'),
),
]
# Generated by Django 5.1.6 on 2025-03-29 22:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("AKModel", "0063_field_validators"),
]
operations = [
migrations.AddField(
model_name="event",
name="export_slot",
field=models.DecimalField(
decimal_places=2,
default=1,
help_text="Slot duration in hours that is used in the timeslot discretization, when this event is exported for the solver.",
max_digits=4,
verbose_name="Export Slot Length",
),
),
]
import decimal
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.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,22 @@ 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'))
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 +182,37 @@ 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):
"""
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
:return: list of category-AK-list-tuples, optionally the additional list of AK wishes
:rtype: list[(AKCategory, list[AK])] [, list[AK]]
"""
......@@ -89,11 +220,26 @@ 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):
"""
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]
"""
return category.ak_set.select_related('event').prefetch_related('owners', 'akslot_set').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):
if filter_func(ak):
if ak.wish:
ak_wishes.append(ak)
else:
......@@ -101,27 +247,425 @@ 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):
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 check_for_data_inconsistency:
export_dict = self.as_json_dict()
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
def as_json_dict(self) -> dict[str, Any]:
"""Return the json representation of this Event.
:return: The json dict representation is constructed
following the input specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
:rtype: dict[str, Any]
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
def _check_event_not_covered(availabilities: list[Availability]) -> bool:
"""Test if event is not covered by availabilities."""
return not Availability.is_event_covered(self, availabilities)
def _check_akslot_fixed_in_timeslot(ak_slot: AKSlot, timeslot: Availability) -> bool:
"""Test if an AKSlot is fixed to overlap a timeslot slot."""
if not ak_slot.fixed or ak_slot.start is None:
return False
fixed_avail = Availability(event=self, start=ak_slot.start, end=ak_slot.end)
return fixed_avail.overlaps(timeslot, strict=True)
def _check_add_constraint(slot: Availability, availabilities: list[Availability]) -> bool:
"""Test if object is not available for whole event and may happen during slot."""
return (
_check_event_not_covered(availabilities) and slot.is_covered(availabilities)
)
def _generate_time_constraints(
avail_label: str,
avail_dict: dict,
timeslot_avail: Availability,
prefix: str = "availability",
) -> list[str]:
return [
f"{prefix}-{avail_label}-{pk}"
for pk, availabilities in avail_dict.items()
if _check_add_constraint(timeslot_avail, availabilities)
]
timeslots = {
"info": {"duration": float(self.export_slot)},
"blocks": [],
}
rooms = Room.objects.filter(event=self).order_by()
slots = AKSlot.objects.filter(event=self).order_by()
ak_availabilities = {
ak.pk: Availability.union(ak.availabilities.all())
for ak in AK.objects.filter(event=self).all()
}
room_availabilities = {
room.pk: Availability.union(room.availabilities.all())
for room in rooms
}
person_availabilities = {
person.pk: Availability.union(person.availabilities.all())
for person in AKOwner.objects.filter(event=self)
}
blocks = list(self.discretize_timeslots())
block_names = []
for block_idx, block in enumerate(blocks):
current_block = []
if not block:
continue
block_start = block[0].avail.start.astimezone(self.timezone)
block_end = block[-1].avail.end.astimezone(self.timezone)
start_day = block_start.strftime("%A, %d. %b")
if block_start.date() == block_end.date():
# same day
time_str = block_start.strftime("%H:%M") + " - " + block_end.strftime("%H:%M")
else:
# different days
time_str = block_start.strftime("%a %H:%M") + " - " + block_end.strftime("%a %H:%M")
block_names.append([start_day, time_str])
block_timeconstraints = [f"notblock{idx}" for idx in range(len(blocks)) if idx != block_idx]
for timeslot in block:
time_constraints = []
# if reso_deadline is set and timeslot ends before it,
# add fulfilled time constraint 'resolution'
if self.reso_deadline is None or timeslot.avail.end < self.reso_deadline:
time_constraints.append("resolution")
# add fulfilled time constraints for all AKs that cannot happen during full event
time_constraints.extend(
_generate_time_constraints("ak", ak_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all persons that are not available for full event
time_constraints.extend(
_generate_time_constraints("person", person_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all rooms that are not available for full event
time_constraints.extend(
_generate_time_constraints("room", room_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all AKSlots fixed to happen during timeslot
time_constraints.extend([
f"fixed-akslot-{slot.id}"
for slot in AKSlot.objects.filter(event=self, fixed=True).exclude(start__isnull=True)
if _check_akslot_fixed_in_timeslot(slot, timeslot.avail)
])
time_constraints.extend(timeslot.constraints)
time_constraints.extend(block_timeconstraints)
current_block.append({
"id": timeslot.idx,
"info": {
"start": timeslot.avail.start.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"),
"end": timeslot.avail.end.astimezone(self.timezone).strftime("%Y-%m-%d %H:%M"),
},
"fulfilled_time_constraints": sorted(time_constraints),
})
timeslots["blocks"].append(current_block)
timeslots["info"]["blocknames"] = block_names
info_dict = {
"title": self.name,
"slug": self.slug
}
for attr in ["contact_email", "place"]:
if hasattr(self, attr) and getattr(self, attr):
info_dict[attr] = getattr(self, attr)
return {
"participants": [],
"rooms": [r.as_json_dict() for r in rooms],
"timeslots": timeslots,
"info": info_dict,
"aks": [ak.as_json_dict() for ak in slots],
}
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 +685,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 +724,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 +744,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 +759,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 +793,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()
......@@ -234,23 +819,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'))
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']
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 +876,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,7 +889,7 @@ 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')
......@@ -295,52 +904,132 @@ 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}
{_('Interest')}: {self.interest}
{_("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 list of stringified representations of all owners
:return: list of owners
:rtype: list[str]
"""
return ", ".join(str(owner) for owner in self.owners.all())
@property
def durations_list(self):
"""
Get a list of stringified representations of all durations of associated slots
:return: list of durations
:rtype: list[str]
"""
return ", ".join(str(slot.duration_simplified) for slot in self.akslot_set.select_related('event').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.
......@@ -364,6 +1053,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
......@@ -371,6 +1066,44 @@ class Room(models.Model):
def __str__(self):
return self.title
def as_json_dict(self) -> dict[str, Any]:
"""Return a json representation of this room object.
:return: The json dict representation is constructed
following the input specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
:rtype: dict[str, Any]
"""
# 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}"]
data = {
"id": self.pk,
"info": {
"name": self.name,
},
"capacity": self.capacity,
"fulfilled_room_constraints": [constraint.name
for constraint in self.properties.all()],
"time_constraints": time_constraints
}
data["fulfilled_room_constraints"].append(f"fixed-room-{self.pk}")
if not any(constr.startswith("proxy") for constr in data["fulfilled_room_constraints"]):
data["fulfilled_room_constraints"].append("no-proxy")
data["fulfilled_room_constraints"].sort()
return data
class AKSlot(models.Model):
""" An AK Mapping matches an AK to a room during a certain time.
......@@ -429,7 +1162,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):
......@@ -448,10 +1182,107 @@ 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 as_json_dict(self) -> dict[str, Any]:
"""Return a json representation of the AK object of this slot.
:return: The json dict representation is constructed
following the input specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
:rtype: dict[str, Any]
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
# check if ak resp. owner is available for the whole event
# -> no time constraint needs to be introduced
if self.fixed and self.start is not None:
ak_time_constraints = [f"fixed-akslot-{self.id}"]
elif not Availability.is_event_covered(self.event, self.ak.availabilities.all()):
ak_time_constraints = [f"availability-ak-{self.ak.pk}"]
else:
ak_time_constraints = []
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}"]
conflict_slots = AKSlot.objects.filter(ak__in=self.ak.conflicts.all())
dependency_slots = AKSlot.objects.filter(ak__in=self.ak.prerequisites.all())
other_ak_slots = AKSlot.objects.filter(ak=self.ak).exclude(pk=self.pk)
ceil_offet_eps = decimal.Decimal(1e-4)
data = {
"id": self.pk,
"duration": math.ceil(self.duration / self.event.export_slot - ceil_offet_eps),
"properties": {
"conflicts":
sorted(
[conflict.pk for conflict in conflict_slots.all()]
+ [second_slot.pk for second_slot in other_ak_slots.all()]
),
"dependencies": sorted([dep.pk for dep in dependency_slots.all()]),
},
"room_constraints": [constraint.name
for constraint in self.ak.requirements.all()],
"time_constraints": ["resolution"] if self.ak.reso else [],
"info": {
"name": self.ak.name,
"head": ", ".join([str(owner)
for owner in self.ak.owners.all()]),
"description": self.ak.description,
"reso": self.ak.reso,
"duration_in_hours": float(self.duration),
"django_ak_id": self.ak.pk,
"types": list(self.ak.types.values_list("name", flat=True).order_by()),
},
}
data["time_constraints"].extend(ak_time_constraints)
for owner in self.ak.owners.all():
data["time_constraints"].extend(_owner_time_constraints(owner))
if self.ak.category:
category_constraints = AKCategory.create_category_optimizer_constraints([self.ak.category])
data["time_constraints"].extend(category_constraints)
if self.fixed and self.room is not None:
data["room_constraints"].append(f"fixed-room-{self.room.pk}")
if not any(constr.startswith("proxy") for constr in data["room_constraints"]):
data["room_constraints"].append("no-proxy")
data["room_constraints"].sort()
data["time_constraints"].sort()
return data
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"),
......@@ -459,6 +1290,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')
......@@ -470,12 +1303,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')
......@@ -483,13 +1328,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')
......@@ -501,6 +1349,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'),
......@@ -551,22 +1400,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
......@@ -585,7 +1449,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)
......@@ -606,7 +1473,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)
......@@ -655,6 +1525,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')
......@@ -667,22 +1542,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):
......
......@@ -4,36 +4,54 @@ from AKModel.models import AK, Room, AKSlot, AKTrack, AKCategory, AKOwner
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 +59,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 tz %}
{% block content %}
<div class="mb-3">
<label for="export_single_line" class="form-label">Exported JSON:</label>
<input readonly type="text" name="export_single_line" class="form-control form-control-sm" value="{{ json_data_oneline }}" style="font-family:var(--font-family-monospace);">
</div>
<div class="mb-3">
<label class="form-label">Exported JSON (indented for better readability):</label>
<pre class="prettyprint border rounded p-2">{{ json_data }}</pre>
</div>
<script>
$(document).ready(function() {
// JSON highlighting.
prettyPrint();
}
)
</script>
{% 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" %}
......
{% extends "admin/base_site.html" %}
{% load tags_AKModel %}
{% load i18n %}
{% load django_bootstrap5 %}
{% load fontawesome_6 %}
{% block title %}{{event}}: {{ title }}{% endblock %}
{% block content %}
{% block action_preview %}
<p>
{{ preview|linebreaksbr }}
</p>
{% endblock %}
<form enctype="multipart/form-data" method="post">{% csrf_token %}
{% bootstrap_form form %}
<div class="float-end">
<button type="submit" class="save btn btn-success" value="Submit">
{% fa6_icon "check" 'fas' %} {% trans "Confirm" %}
</button>
</div>
<a href="javascript:history.back()" class="btn btn-info">
{% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
</a>
</form>
{% endblock %}