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

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
Show changes
Showing
with 1152 additions and 44 deletions
......@@ -8,13 +8,13 @@ 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):
......@@ -35,6 +35,19 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
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'
......@@ -51,6 +64,7 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
'reso': _("Reso intention?"),
'category': _("Category (for Wishes)"),
'wishes': _("Wishes"),
'types': _("Types"),
}
def build_ak_list_with_next_aks(ak_list):
......@@ -58,23 +72,36 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
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=[])]
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)))
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)
......@@ -130,6 +157,28 @@ 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):
"""
......@@ -157,6 +206,31 @@ 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
......@@ -236,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"
from django.apps import apps
from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from AKModel.metaviews import status_manager
......@@ -77,10 +76,15 @@ class EventRoomsWidget(TemplateStatusWidget):
def render_actions(self, context: {}) -> list[dict]:
actions = super().render_actions(context)
# Action has to be added here since it depends on the event for URL building
import_room_url = reverse_lazy("admin:room-import", kwargs={"event_slug": context["event"].slug})
for action in actions:
if action["url"] == import_room_url:
return actions
actions.append(
{
"text": _("Import Rooms from CSV"),
"url": reverse_lazy("admin:room-import", kwargs={"event_slug": context["event"].slug}),
"url": import_room_url,
}
)
return actions
......@@ -112,24 +116,19 @@ class EventAKsWidget(TemplateStatusWidget):
]
if apps.is_installed("AKScheduling"):
actions.extend([
{
"text": format_html('{} <span class="badge bg-secondary">{}</span>',
_("Constraint Violations"),
context["event"].constraintviolation_set.count()),
"url": reverse_lazy("admin:constraint-violations", kwargs={"slug": context["event"].slug}),
},
{
"text": _("AKs requiring special attention"),
"url": reverse_lazy("admin:special-attention", kwargs={"slug": context["event"].slug}),
},
{
])
if context["event"].ak_set.count() > 0:
actions.append({
"text": _("Enter Interest"),
"url": reverse_lazy("admin:enter-interest",
kwargs={"event_slug": context["event"].slug,
"pk": context["event"].ak_set.all().first().pk}
),
},
])
})
actions.extend([
{
"text": _("Edit Default Slots"),
......@@ -153,6 +152,17 @@ class EventAKsWidget(TemplateStatusWidget):
},
]
)
if apps.is_installed("AKSolverInterface"):
actions.extend([
{
"text": _("Export AKs as JSON"),
"url": reverse_lazy("admin:ak_json_export", kwargs={"event_slug": context["event"].slug}),
},
{
"text": _("Import AK schedule from JSON"),
"url": reverse_lazy("admin:ak_schedule_json_import", kwargs={"event_slug": context["event"].slug}),
},
])
return actions
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-15 20:03+0200\n"
"POT-Creation-Date: 2025-06-16 12:44+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -38,45 +38,65 @@ msgstr "Veranstaltung"
#: AKPlan/templates/AKPlan/plan_index.html:59
#: AKPlan/templates/AKPlan/plan_room.html:13
#: AKPlan/templates/AKPlan/plan_room.html:59
#: AKPlan/templates/AKPlan/plan_wall.html:65
#: AKPlan/templates/AKPlan/plan_wall.html:67
msgid "Room"
msgstr "Raum"
#: AKPlan/templates/AKPlan/plan_index.html:80
#: AKPlan/templates/AKPlan/plan_index.html:120
#: AKPlan/templates/AKPlan/plan_room.html:11
#: AKPlan/templates/AKPlan/plan_track.html:9
msgid "AK Plan"
msgstr "AK-Plan"
#: AKPlan/templates/AKPlan/plan_index.html:92
#: AKPlan/templates/AKPlan/plan_index.html:134
#: AKPlan/templates/AKPlan/plan_room.html:49
msgid "Rooms"
msgstr "Räume"
#: AKPlan/templates/AKPlan/plan_index.html:105
#: AKPlan/templates/AKPlan/plan_index.html:147
#: AKPlan/templates/AKPlan/plan_track.html:36
msgid "Tracks"
msgstr "Tracks"
#: AKPlan/templates/AKPlan/plan_index.html:117
#: AKPlan/templates/AKPlan/plan_index.html:159
msgid "AK Wall"
msgstr "AK-Wall"
#: AKPlan/templates/AKPlan/plan_index.html:130
#: AKPlan/templates/AKPlan/plan_wall.html:130
#: AKPlan/templates/AKPlan/plan_index.html:165
msgid "Plan:"
msgstr "Plan:"
#: AKPlan/templates/AKPlan/plan_index.html:171
msgid "Filter by types"
msgstr "Nach Typen filtern"
#: AKPlan/templates/AKPlan/plan_index.html:174
msgid "Types:"
msgstr "Typen:"
#: AKPlan/templates/AKPlan/plan_index.html:182
msgid "AKs without type"
msgstr "AKs ohne Typ"
#: AKPlan/templates/AKPlan/plan_index.html:186
msgid "No AKs with additional other types"
msgstr "Keine AKs, die noch zusätzlich andere Typen haben"
#: AKPlan/templates/AKPlan/plan_index.html:198
#: AKPlan/templates/AKPlan/plan_wall.html:132
msgid "Current AKs"
msgstr "Aktuelle AKs"
#: AKPlan/templates/AKPlan/plan_index.html:137
#: AKPlan/templates/AKPlan/plan_wall.html:135
#: AKPlan/templates/AKPlan/plan_index.html:205
#: AKPlan/templates/AKPlan/plan_wall.html:137
msgid "Next AKs"
msgstr "Nächste AKs"
#: AKPlan/templates/AKPlan/plan_index.html:145
#: AKPlan/templates/AKPlan/plan_index.html:213
msgid "This event is not active."
msgstr "Dieses Event ist nicht aktiv."
#: AKPlan/templates/AKPlan/plan_index.html:158
#: AKPlan/templates/AKPlan/plan_index.html:226
#: AKPlan/templates/AKPlan/plan_room.html:77
#: AKPlan/templates/AKPlan/plan_track.html:58
msgid "Plan is not visible (yet)."
......@@ -99,10 +119,14 @@ msgstr "Eigenschaften"
msgid "Track"
msgstr "Track"
#: AKPlan/templates/AKPlan/plan_wall.html:145
#: AKPlan/templates/AKPlan/plan_wall.html:147
msgid "Reload page automatically?"
msgstr "Seite automatisch neu laden?"
#: AKPlan/templates/AKPlan/slots_table.html:14
msgid "No AKs"
msgstr "Keine AKs"
#: AKPlan/views.py:64
msgid "Invalid type filter"
msgstr "Ungültiger Typ-Filter"
......@@ -70,6 +70,46 @@
}
});
</script>
{% if type_filtering_active %}
{# Type filter script #}
<script type="module">
const { createApp } = Vue
createApp({
delimiters: ["[[", "]]"],
data() {
return {
types: JSON.parse("{{ types | escapejs }}"),
strict: {{ types_filter_strict|yesno:"true,false" }},
empty: {{ types_filter_empty|yesno:"true,false" }}
}
},
methods: {
onFilterChange(type) {
// Re-generate filter url
const typeString = "types="
+ this.types
.map(t => `${t.slug}:${t.state ? 'yes' : 'no'}`)
.join(',')
+ `&strict=${this.strict ? 'yes' : 'no'}`
+ `&empty=${this.empty ? 'yes' : 'no'}`;
// Redirect to the new url including the adjusted filters
const baseUrl = window.location.origin + window.location.pathname;
window.location.href = `${baseUrl}?${typeString}`;
}
}
}).mount('#filter');
// Prevent showing of cached form values for filter inputs when using broswer navigation
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
window.location.reload();
}
});
</script>
{% endif %}
{% endif %}
{% endblock %}
......@@ -83,6 +123,8 @@
{% block content %}
{% include "messages.html" %}
<div class="float-end">
<ul class="nav nav-pills">
{% if rooms|length > 0 %}
......@@ -114,13 +156,39 @@
{% if event.active %}
<li class="nav-item">
<a class="nav-link active"
href="{% url 'plan:plan_wall' event_slug=event.slug %}">{% fa6_icon 'desktop' 'fas' %}&nbsp;&nbsp;{% trans "AK Wall" %}</a>
href="{% url 'plan:plan_wall' event_slug=event.slug %}?{{ query_string }}">{% fa6_icon 'desktop' 'fas' %}&nbsp;&nbsp;{% trans "AK Wall" %}</a>
</li>
{% endif %}
</ul>
</div>
<h1>Plan: {{ event }}</h1>
<h1 class="mb-3">{% trans "Plan:" %} {{ event }}</h1>
{% if type_filtering_active %}
{# Type filter HTML #}
<div class="card border-primary mb-3">
<div class="card-header">
{% trans 'Filter by types' %}
</div>
<div class="card-body d-flex" id="filter">
{% trans "Types:" %}
<div id="filterTypes" class="d-flex">
<div class="form-check form-switch ms-3" v-for="type in types">
<label class="form-check-label" :for="'typeFilterType' + type.slug">[[ type.name ]]</label>
<input class="form-check-input" type="checkbox" :id="'typeFilterType' + type.slug " v-model="type.state" @change="onFilterChange()">
</div>
</div>
<div class="form-check form-switch ms-5">
<label class="form-check-label" for="typeFilterEmpty">{% trans "AKs without type" %}</label>
<input class="form-check-input" type="checkbox" id="typeFilterEmpty" v-model="empty" @change="onFilterChange()">
</div>
<div class="form-check form-switch ms-5">
<label class="form-check-label" for="typeFilterStrict">{% trans "No AKs with additional other types" %}</label>
<input class="form-check-input" type="checkbox" id="typeFilterStrict" v-model="strict" @change="onFilterChange()">>
</div>
</div>
</div>
{% endif %}
{% timezone event.timezone %}
<div class="row" style="margin-top:30px;">
......
......@@ -51,6 +51,8 @@
start: '{{ start | timezone:event.timezone | date:"Y-m-d H:i:s" }}',
end: '{{ end | timezone:event.timezone | date:"Y-m-d H:i:s"}}',
},
slotMinTime: '{{ earliest_start_hour }}:00:00',
slotMaxTime: '{{ latest_end_hour }}:00:00',
eventDidMount: function(info) {
$(info.el).tooltip({title: info.event.extendedProps.description});
},
......
from django.test import TestCase
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
class PlanViewTests(BasicViewTests, TestCase):
......
import json
from datetime import datetime, timedelta
from django.conf import settings
from django.contrib import messages
from django.db.models import Q
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.datetime_safe import datetime
from django.views.generic import ListView, DetailView
from django.views.generic import DetailView, ListView
from django.utils.translation import gettext_lazy as _
from AKModel.models import AKSlot, Room, AKTrack
from AKModel.metaviews.admin import FilterByEventSlugMixin
from AKModel.models import AKSlot, AKTrack, Room, AKType
class PlanIndexView(FilterByEventSlugMixin, ListView):
......@@ -18,10 +23,69 @@ class PlanIndexView(FilterByEventSlugMixin, ListView):
template_name = "AKPlan/plan_index.html"
context_object_name = "akslots"
ordering = "start"
types_filter = None
query_string = ""
def get(self, request, *args, **kwargs):
if 'types' in request.GET:
try:
# Initialize types filter, has to be done here such that it is not reused across requests
self.types_filter = {
"yes": [],
"no": [],
"no_set": set(),
"strict": False,
"empty": False,
}
# If types are given, filter the queryset accordingly
types_raw = request.GET['types'].split(',')
for t in types_raw:
type_slug, type_condition = t.split(':')
if type_condition in ["yes", "no"]:
t = AKType.objects.get(slug=type_slug, event=self.event)
self.types_filter[type_condition].append(t)
if type_condition == "no":
# Store slugs of excluded types in a set for faster lookup
self.types_filter["no_set"].add(t.slug)
else:
raise ValueError(f"Unknown type condition: {type_condition}")
if 'strict' in request.GET:
# If strict is specified and marked as "yes",
# exclude all AKs that have any of the excluded types ("no"),
# even if they have other types that are marked as "yes"
self.types_filter["strict"] = request.GET.get('strict') == 'yes'
if 'empty' in request.GET:
# If empty is specified and marked as "yes", include AKs that have no types at all
self.types_filter["empty"] = request.GET.get('empty') == 'yes'
# Will be used for generating a link to the wall view with the same filter
self.query_string = request.GET.urlencode(safe=",:")
except (ValueError, AKType.DoesNotExist):
# Display an error message if the types parameter is malformed
messages.add_message(request, messages.ERROR, _("Invalid type filter"))
self.types_filter = None
s = super().get(request, *args, **kwargs)
return s
def get_queryset(self):
# Ignore slots not scheduled yet
return super().get_queryset().filter(start__isnull=False).select_related('ak', 'room', 'ak__category')
qs = (super().get_queryset().filter(start__isnull=False).
select_related('event', 'ak', 'room', 'ak__category', 'ak__event'))
# Need to prefetch both event and ak__event
# since django is not aware that the two are always the same
# Apply type filter if necessary
if self.types_filter:
# Either include all AKs with the given types or without any types at all
if self.types_filter["empty"]:
qs = qs.filter(Q(ak__types__in=self.types_filter["yes"]) | Q(ak__types__isnull=True)).distinct()
# Or only those with the given types
else:
qs = qs.filter(ak__types__in=self.types_filter["yes"]).distinct()
# Afterwards, if strict, exclude all AKs that have any of the excluded types,
# even though they were included by the previous filter
if self.types_filter["strict"]:
qs = qs.exclude(ak__types__in=self.types_filter["no"]).distinct()
return qs
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
......@@ -37,6 +101,7 @@ class PlanIndexView(FilterByEventSlugMixin, ListView):
# Get list of current and next slots
for akslot in context["akslots"]:
self._process_slot(akslot)
# Construct a list of all rooms used by these slots on the fly
if akslot.room is not None:
rooms.add(akslot.room)
......@@ -59,8 +124,38 @@ class PlanIndexView(FilterByEventSlugMixin, ListView):
context["tracks"] = self.event.aktrack_set.all()
# Pass query string to template for generating a matching wall link
context["query_string"] = self.query_string
# Generate a list of all types and their current selection state for graphic filtering
types = [{"name": t.name, "slug": t.slug, "state": True} for t in self.event.aktype_set.all()]
if len(types) > 0:
context["type_filtering_active"] = True
if self.types_filter:
for t in types:
if t["slug"] in self.types_filter["no_set"]:
t["state"] = False
# Pass type list as well as filter state for strict filtering and empty types to the template
context["types"] = json.dumps(types)
context["types_filter_strict"] = False
context["types_filter_empty"] = False
if self.types_filter:
context["types_filter_empty"] = self.types_filter["empty"]
context["types_filter_strict"] = self.types_filter["strict"]#
else:
context["type_filtering_active"] = False
return context
def _process_slot(self, akslot):
"""
Function to be called for each slot when looping over the slots
(meant to be overridden in inherited views)
:param akslot: current slot
:type akslot: AKSlot
"""
class PlanScreenView(PlanIndexView):
"""
......@@ -81,11 +176,12 @@ class PlanScreenView(PlanIndexView):
return redirect(reverse_lazy("plan:plan_overview", kwargs={"event_slug": self.event.slug}))
return s
"""
# pylint: disable=attribute-defined-outside-init
def get_queryset(self):
# Determine interesting range (some hours ago until some hours in the future as specified in the settings)
now = datetime.now().astimezone(self.event.timezone)
# Wall during event: Adjust, show only parts in the future
if self.event.start < now < self.event.end:
# Determine interesting range (some hours ago until some hours in the future as specified in the settings)
self.start = now - timedelta(hours=settings.PLAN_WALL_HOURS_RETROSPECT)
else:
self.start = self.event.start
......@@ -94,14 +190,31 @@ class PlanScreenView(PlanIndexView):
# Restrict AK slots to relevant ones
# This will automatically filter all rooms not needed for the selected range in the orginal get_context method
return super().get_queryset().filter(start__gt=self.start)
"""
def get_context_data(self, *, object_list=None, **kwargs):
# Find the earliest hour AKs start and end (handle 00:00 as 24:00)
self.earliest_start_hour = 23
self.latest_end_hour = 1
context = super().get_context_data(object_list=object_list, **kwargs)
context["start"] = self.event.start
context["start"] = self.start
context["end"] = self.event.end
context["earliest_start_hour"] = self.earliest_start_hour
context["latest_end_hour"] = self.latest_end_hour
return context
def _process_slot(self, akslot):
start_hour = akslot.start.astimezone(self.event.timezone).hour
if start_hour < self.earliest_start_hour:
# Use hour - 1 to improve visibility of date change
self.earliest_start_hour = max(start_hour - 1, 0)
end_hour = akslot.end.astimezone(self.event.timezone).hour
# Special case: AK starts before but ends after midnight -- show until midnight
if end_hour < start_hour:
self.latest_end_hour = 24
elif end_hour > self.latest_end_hour:
# Always use hour + 1, since AK may end at :xy and not always at :00
self.latest_end_hour = min(end_hour + 1, 24)
class PlanRoomView(FilterByEventSlugMixin, DetailView):
"""
......@@ -131,7 +244,7 @@ class PlanTrackView(FilterByEventSlugMixin, DetailView):
context = super().get_context_data(object_list=object_list, **kwargs)
# Restrict AKSlot list to given track
# while joining AK, room and category information to reduce the amount of necessary SQL queries
context["slots"] = AKSlot.objects.\
filter(event=self.event, ak__track=context['track']).\
context["slots"] = AKSlot.objects. \
filter(event=self.event, ak__track=context['track']). \
select_related('ak', 'room', 'ak__category')
return context
......@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""
import decimal
import os
from django.utils.translation import gettext_lazy as _
......@@ -38,6 +39,8 @@ INSTALLED_APPS = [
'AKScheduling.apps.AkschedulingConfig',
'AKPlan.apps.AkplanConfig',
'AKOnline.apps.AkonlineConfig',
'AKPreference.apps.AkpreferenceConfig',
'AKSolverInterface.apps.AksolverinterfaceConfig',
'AKModel.apps.AKAdminConfig',
'django.contrib.auth',
'django.contrib.contenttypes',
......@@ -217,6 +220,15 @@ PLAN_MAX_HIGHLIGHT_UPDATE_SECONDS = 2 * 60 * 60
DASHBOARD_SHOW_RECENT = True
# How many entries max?
DASHBOARD_RECENT_MAX = 25
# How many events should be featured in the dashboard
# (active events will always be featured, even if their number is higher than this threshold)
DASHBOARD_MAX_FEATURED_EVENTS = 3
# In the export to the solver we need to calculate the integer number
# of discrete time slots covered by an AK. This is done by rounding
# the 'exact' number up. To avoid 'overshooting' by 1
# due to FLOP inaccuracies, we subtract this small epsilon before rounding.
EXPORT_CEIL_OFFSET_EPS = decimal.Decimal(1e-4)
# Registration/login behavior
SIMPLE_BACKEND_REDIRECT_URL = "/user/"
......@@ -224,7 +236,7 @@ LOGIN_REDIRECT_URL = SIMPLE_BACKEND_REDIRECT_URL
# Content Security Policy
CSP_DEFAULT_SRC = ("'self'",)
CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'")
CSP_SCRIPT_SRC = ("'self'", "'unsafe-inline'", "'unsafe-eval'")
CSP_STYLE_SRC = ("'self'", "'unsafe-inline'", "fonts.googleapis.com")
CSP_IMG_SRC = ("'self'", "data:")
CSP_FRAME_SRC = ("'self'", )
......
......@@ -37,3 +37,5 @@ if apps.is_installed("AKDashboard"):
urlpatterns.append(path('', include('AKDashboard.urls', namespace='dashboard')))
if apps.is_installed("AKPlan"):
urlpatterns.append(path('', include('AKPlan.urls', namespace='plan')))
if apps.is_installed("AKPreference"):
urlpatterns.append(path('', include('AKPreference.urls', namespace='poll')))
from django import forms
from django.contrib import admin
from AKPreference.models import AKPreference, EventParticipant
from AKModel.admin import PrepopulateWithNextActiveEventMixin, EventRelatedFieldListFilter
from AKModel.models import AK
@admin.register(EventParticipant)
class EventParticipantAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin):
"""
Admin interface for EventParticipant
"""
model = EventParticipant
list_display = ['name', 'institution', 'event']
list_filter = ['event', 'institution']
list_editable = []
ordering = ['name']
class AKPreferenceAdminForm(forms.ModelForm):
"""
Adapted admin form for AK preferences for usage in :class:`AKPreferenceAdmin`)
"""
class Meta:
widgets = {
'participant': forms.Select,
'ak': forms.Select,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter possible values for foreign keys & m2m when event is specified
if hasattr(self.instance, "event") and self.instance.event is not None:
self.fields["participant"].queryset = EventParticipant.objects.filter(event=self.instance.event)
self.fields["ak"].queryset = AK.objects.filter(event=self.instance.event)
@admin.register(AKPreference)
class AKPreferenceAdmin(PrepopulateWithNextActiveEventMixin, admin.ModelAdmin):
"""
Admin interface for AK preferences.
Uses an adapted form (see :class:`AKPreferenceAdminForm`)
"""
model = AKPreference
form = AKPreferenceAdminForm
list_display = ['preference', 'participant', 'ak', 'event']
list_filter = ['event', ('ak', EventRelatedFieldListFilter), ('participant', EventRelatedFieldListFilter)]
list_editable = []
ordering = ['participant', 'preference', 'ak']
from django.apps import AppConfig
class AkpreferenceConfig(AppConfig):
"""
App configuration (default, only specifies name of the app)
"""
name = "AKPreference"
from django import forms
from django.utils.translation import gettext_lazy as _
from AKModel.availability.forms import AvailabilitiesFormMixin
from AKModel.availability.models import Availability
from AKModel.models import AKRequirement
from AKPreference.models import EventParticipant
class EventParticipantForm(AvailabilitiesFormMixin, forms.ModelForm):
"""Form to add EventParticipants"""
required_css_class = "required"
class Meta:
model = EventParticipant
fields = [
"name",
"institution",
"requirements",
"event",
]
widgets = {
"requirements": forms.CheckboxSelectMultiple,
"event": forms.HiddenInput,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial = {**self.initial, **kwargs["initial"]}
self.fields["requirements"].queryset = AKRequirement.objects.filter(
event=self.initial.get("event"), relevant_for_participants=True
)
# Don't ask for requirements if there are no requirements configured for this event
if self.fields["requirements"].queryset.count() == 0:
self.fields.pop("requirements")
def clean_availabilities(self):
"""
Automatically improve availabilities entered.
If the user did not specify availabilities assume the full event duration is possible
:return: cleaned availabilities
(either user input or one availability for the full length of the event if user input was empty)
"""
availabilities = super().clean_availabilities()
if len(availabilities) == 0:
availabilities.append(
Availability.with_event_length(event=self.cleaned_data["event"])
)
return availabilities
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-18 12:09+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: AKPreference/models.py:11 AKPreference/models.py:93
msgid "Participant"
msgstr "Teilnehmende*r"
#: AKPreference/models.py:12
msgid "Participants"
msgstr "Teilnehmende"
#: AKPreference/models.py:15
msgid "Nickname"
msgstr "Spitzname"
#: AKPreference/models.py:16
msgid ""
"Name to identify a participant by (in case of questions from the organizers)"
msgstr "Name, zur Identifikation bei Rückfragen von den Organisator*innen"
#: AKPreference/models.py:17
msgid "Institution"
msgstr "Institution"
#: AKPreference/models.py:17
msgid "Uni etc."
msgstr "Uni etc."
#: AKPreference/models.py:19 AKPreference/models.py:90
msgid "Event"
msgstr "Event"
#: AKPreference/models.py:20 AKPreference/models.py:91
msgid "Associated event"
msgstr "Zugehöriges Event"
#: AKPreference/models.py:22
msgid "Requirements"
msgstr "Anforderungen"
#: AKPreference/models.py:23
msgid "Participant's Requirements"
msgstr "Anforderungen des*der Teilnehmer*in"
#: AKPreference/models.py:26
#, python-brace-format
msgid "Anonymous {pk}"
msgstr "Anonym {pk}"
#: AKPreference/models.py:85
msgid "AK Preference"
msgstr "AK Präferenz"
#: AKPreference/models.py:86
msgid "AK Preferences"
msgstr "AK-Präferenzen"
#: AKPreference/models.py:94
msgid "Participant this preference belongs to"
msgstr "Teilnehmer*in, zu dem*der die Präferenz gehört"
#: AKPreference/models.py:96
msgid "AK"
msgstr "AK"
#: AKPreference/models.py:97
msgid "AK this preference belongs to"
msgstr "AK zu dem die Präferenz gehört"
#: AKPreference/models.py:103
msgid "Ignore"
msgstr "Ignorieren"
#: AKPreference/models.py:104
msgid "Interested"
msgstr "Interessiert"
#: AKPreference/models.py:105
msgid "Great interest"
msgstr "Großes Interesse"
#: AKPreference/models.py:106
msgid "Required"
msgstr "Erforderlich"
#: AKPreference/models.py:108
msgid "Preference"
msgstr "Präferenz"
#: AKPreference/models.py:109
msgid "Preference level for the AK"
msgstr "Präferenz-Level für diesen AK"
#: AKPreference/models.py:113
msgid "Timestamp"
msgstr "Zeitpunkt"
#: AKPreference/models.py:113
msgid "Time of creation"
msgstr "Erstellungszeitpunkt"
#: AKPreference/templates/AKPreference/poll.html:11
#: AKPreference/templates/AKPreference/poll_not_configured.html:7
msgid "AKs"
msgstr "AKs"
#: AKPreference/templates/AKPreference/poll.html:11
msgid "Preferences"
msgstr "Präferenzen"
#: AKPreference/templates/AKPreference/poll.html:34
#: AKPreference/templates/AKPreference/poll.html:41
#: AKPreference/templates/AKPreference/poll_not_configured.html:7
#: AKPreference/templates/AKPreference/poll_not_configured.html:11
msgid "AK Preference Poll"
msgstr "Abfrage von AK-Präferenzen"
#: AKPreference/templates/AKPreference/poll.html:47
msgid "Your AK preferences"
msgstr "Deine AK Präferenzen"
#: AKPreference/templates/AKPreference/poll.html:49
msgid "Please enter your preferences."
msgstr "Trage bitte deine Präferenzen zu den AKs ein."
#: AKPreference/templates/AKPreference/poll.html:70
#: AKPreference/templates/AKPreference/poll.html:79
msgid "Intends to submit a resolution"
msgstr "Beabsichtigt eine Resolution einzureichen"
#: AKPreference/templates/AKPreference/poll.html:75
msgid "Responsible"
msgstr "Verantwortlich"
#: AKPreference/templates/AKPreference/poll.html:93
msgid "Submit"
msgstr "Eintragen"
#: AKPreference/templates/AKPreference/poll.html:97
msgid "Reset Form"
msgstr "Formular leeren"
#: AKPreference/templates/AKPreference/poll.html:101
msgid "Cancel"
msgstr "Abbrechen"
#: AKPreference/templates/AKPreference/poll_base.html:13
msgid "Write to organizers of this event for questions and comments"
msgstr "Fragen oder Kommentare? Schreib den Orgas dieses Events eine Mail"
#: AKPreference/templates/AKPreference/poll_not_configured.html:20
msgid ""
"System is not yet configured for AK preference polling. Please try again "
"later."
msgstr ""
"Das System ist bisher nicht für die Erfassung von AK-Präferenzen "
"konfiguriert. Bitte versuche es später wieder."
#: AKPreference/views.py:37
msgid "AK preferences were registered successfully"
msgstr "AK-Präferenzen erfolgreich registriert"
#: AKPreference/views.py:138
msgid ""
"Something went wrong. Your preferences were not saved. Please try again or "
"contact the organizers."
msgstr ""
"Etwas ging schief. Deine Präferenzen konnten nicht gespeichert werden. "
"Versuche es bitte erneut oder kontaktiere die Orgas."
# Generated by Django 5.2.1 on 2025-06-14 18:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("AKModel", "0067_eventparticipant_requirements_and_more"),
]
operations = [
migrations.CreateModel(
name="EventParticipant",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(
blank=True,
help_text="Name to identify a participant by (in case of questions from the organizers)",
max_length=64,
verbose_name="Nickname",
),
),
(
"institution",
models.CharField(
blank=True,
help_text="Uni etc.",
max_length=128,
verbose_name="Institution",
),
),
(
"event",
models.ForeignKey(
help_text="Associated event",
on_delete=django.db.models.deletion.CASCADE,
to="AKModel.event",
verbose_name="Event",
),
),
(
"requirements",
models.ManyToManyField(
blank=True,
help_text="Participant's Requirements",
to="AKModel.akrequirement",
verbose_name="Requirements",
),
),
],
options={
"verbose_name": "Participant",
"verbose_name_plural": "Participants",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="AKPreference",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"preference",
models.PositiveSmallIntegerField(
choices=[
(0, "Ignore"),
(1, "Interested"),
(2, "Great interest"),
(3, "Required"),
],
default=0,
help_text="Preference level for the AK",
verbose_name="Preference",
),
),
(
"timestamp",
models.DateTimeField(
auto_now_add=True,
help_text="Time of creation",
verbose_name="Timestamp",
),
),
(
"ak",
models.ForeignKey(
help_text="AK this preference belongs to",
on_delete=django.db.models.deletion.CASCADE,
to="AKModel.ak",
verbose_name="AK",
),
),
(
"event",
models.ForeignKey(
help_text="Associated event",
on_delete=django.db.models.deletion.CASCADE,
to="AKModel.event",
verbose_name="Event",
),
),
(
"participant",
models.ForeignKey(
help_text="Participant this preference belongs to",
on_delete=django.db.models.deletion.CASCADE,
to="AKPreference.eventparticipant",
verbose_name="Participant",
),
),
],
options={
"verbose_name": "AK Preference",
"verbose_name_plural": "AK Preferences",
"ordering": ["-timestamp"],
"unique_together": {("event", "participant", "ak")},
},
),
]
from django.db import models
from django.utils.translation import gettext_lazy as _
from AKModel.models import AK, AKRequirement, Event
class EventParticipant(models.Model):
""" A participant describes a person taking part in an event."""
class Meta:
verbose_name = _('Participant')
verbose_name_plural = _('Participants')
ordering = ['name']
name = models.CharField(max_length=64, blank=True, verbose_name=_('Nickname'),
help_text=_('Name to identify a participant by (in case of questions from the organizers)'))
institution = models.CharField(max_length=128, blank=True, verbose_name=_('Institution'), help_text=_('Uni etc.'))
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
requirements = models.ManyToManyField(to=AKRequirement, blank=True, verbose_name=_('Requirements'),
help_text=_("Participant's Requirements"))
def __str__(self) -> str:
string = _("Anonymous {pk}").format(pk=self.pk) if not self.name else self.name
if self.institution:
string += f" ({self.institution})"
return string
@property
def availabilities(self):
"""
Get all availabilities associated to this EventParticipant
:return: availabilities
:rtype: QuerySet[Availability]
"""
return "Availability".objects.filter(participant=self)
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
avails = self.availabilities.all()
participant_required_prefs = AKPreference.objects.filter(
event=self.event,
participant=self,
preference=AKPreference.PreferenceLevel.REQUIRED,
)
if (
avails
and not Availability.is_event_covered(self.event, avails)
and participant_required_prefs.exists()
):
# participant has restricted availability and is actually required for AKs
return [f"availability-participant-{self.pk}"]
return []
def get_room_constraints(self) -> list[str]:
"""Construct list of required room constraint labels."""
return list(self.requirements.values_list("name", flat=True).order_by())
@property
def export_preferences(self):
"""Preferences of this participant with positive score."""
return (
AKPreference.objects
.filter(
participant=self, preference__gt=0
)
.order_by()
.select_related('ak')
.prefetch_related('ak__akslot_set')
)
class AKPreference(models.Model):
"""Model representing the preference of a participant to an AK."""
class Meta:
verbose_name = _('AK Preference')
verbose_name_plural = _('AK Preferences')
unique_together = [['event', 'participant', 'ak']]
ordering = ["-timestamp"]
event = models.ForeignKey(to=Event, on_delete=models.CASCADE, verbose_name=_('Event'),
help_text=_('Associated event'))
participant = models.ForeignKey(to=EventParticipant, on_delete=models.CASCADE, verbose_name=_('Participant'),
help_text=_('Participant this preference belongs to'))
ak = models.ForeignKey(to=AK, on_delete=models.CASCADE, verbose_name=_('AK'),
help_text=_('AK this preference belongs to'))
class PreferenceLevel(models.IntegerChoices):
"""
Possible preference values
"""
IGNORE = 0, _('Ignore')
PREFER = 1, _('Interested')
STRONG_PREFER = 2, _("Great interest")
REQUIRED = 3, _("Required")
preference = models.PositiveSmallIntegerField(verbose_name=_('Preference'), choices=PreferenceLevel.choices,
help_text=_('Preference level for the AK'),
blank=False,
default=PreferenceLevel.IGNORE)
timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_('Timestamp'), help_text=_('Time of creation'))
def __str__(self) -> str:
return f"Preference {self.get_preference_display()} [of '{self.participant}' for AK '{self.ak}']"
@property
def required(self) -> bool:
"""Whether this preference is a 'REQUIRED'"""
return self.preference == self.PreferenceLevel.REQUIRED
@property
def preference_score(self) -> int:
"""Score of this preference for the solver"""
return self.preference if self.preference != self.PreferenceLevel.REQUIRED else -1
from rest_framework import serializers
from AKModel.models import AKSlot
from AKModel.serializers import StringListField
from AKPreference.models import AKPreference, EventParticipant
class ExportAKPreferenceSerializer(serializers.ModelSerializer):
"""Export serializer for AKPreference objects.
Used to serialize AKPreference objects for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
class Meta:
model = AKPreference
fields = ["required", "preference_score"]
read_only_fields = ["required", "preference_score"]
class ExportAKPreferencePerSlotSerializer(serializers.BaseSerializer):
"""Export serializer to associate AKPreferences with the AK's AKSlot objects.
The AKPreference model stores the preference per AK object.
The solver however needs the serialization to be *per AKSlot*, i.e.
we need to 'repeat' all preferences for each slot of an AK.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
def create(self, validated_data):
raise ValueError("`ExportAKPreferencePerSlotSerializer` is read-only.")
def to_internal_value(self, data):
raise ValueError("`ExportAKPreferencePerSlotSerializer` is read-only.")
def update(self, instance, validated_data):
raise ValueError("`ExportAKPreferencePerSlotSerializer` is read-only.")
def to_representation(self, instance):
preference_queryset = instance
def _insert_akslot_id(serialization_dict: dict, slot: AKSlot) -> dict:
"""Insert id of the slot into the dict and return it."""
# The naming scheme of the solver is confusing.
# Our 'AKSlot' corresponds to the 'AK' of the solver,
# so `ak_id` is the id of the corresponding AKSlot.
serialization_dict["ak_id"] = slot.pk
return serialization_dict
return_lst = []
for pref in preference_queryset:
pref_serialization = ExportAKPreferenceSerializer(pref).data
return_lst.extend([
_insert_akslot_id(pref_serialization, slot)
for slot in pref.ak.akslot_set.all()
])
return return_lst
class ExportParticipantInfoSerializer(serializers.ModelSerializer):
"""Serializer of EventParticipant objects for the 'info' field.
Used in `ExportParticipantSerializer` to serialize EventParticipant objects
for the export to a solver. Part of the implementation of the
format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
name = serializers.CharField(source="__str__")
class Meta:
model = EventParticipant
fields = ["name"]
read_only_fields = ["name"]
class ExportParticipantSerializer(serializers.ModelSerializer):
"""Export serializer for EventParticipant objects.
Used to serialize EventParticipant objects for the export to a solver.
Part of the implementation of the format of the KoMa solver:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format#input--output-format
"""
room_constraints = StringListField(source="get_room_constraints")
time_constraints = StringListField(source="get_time_constraints")
preferences = ExportAKPreferencePerSlotSerializer(source="export_preferences")
info = ExportParticipantInfoSerializer(source="*")
class Meta:
model = EventParticipant
fields = ["id", "info", "room_constraints", "time_constraints", "preferences"]
read_only_fields = ["id", "info", "room_constraints", "time_constraints", "preferences"]
{% extends 'AKPreference/poll_base.html' %}
{% load i18n %}
{% load django_bootstrap5 %}
{% load fontawesome_6 %}
{% load static %}
{% load tz %}
{% load static %}
{% block title %}{% trans "AKs" %}: {{ event.name }} - {% trans "Preferences" %}{% endblock %}
{% block imports %}
{% include "AKModel/load_fullcalendar_availabilities.html" %}
<link rel="stylesheet" type='text/css' href="{% static 'common/css/poll.css' %}">
<script>
{% get_current_language as LANGUAGE_CODE %}
document.addEventListener('DOMContentLoaded', function () {
createAvailabilityEditors(
'{{ event.timezone }}',
'{{ LANGUAGE_CODE }}',
'{{ event.start | timezone:event.timezone | date:"Y-m-d H:i:s" }}',
'{{ event.end | timezone:event.timezone | date:"Y-m-d H:i:s" }}'
);
});
</script>
{% endblock %}
{% block breadcrumbs %}
{% include "AKPreference/poll_breadcrumbs.html" %}
<li class="breadcrumb-item active">{% trans "AK Preference Poll" %}</li>
{% endblock %}
{% block content %}
{% include "messages.html" %}
{% block headline %}
<h2>{% trans 'AK Preference Poll' %}</h2>
{% endblock %}
<form method="POST" class="post-form">{% csrf_token %}
{% block form_contents %}
{% bootstrap_form participant_form %}
<section>
<h3>{% trans "Your AK preferences" %}</h3>
<p>{% trans "Please enter your preferences." %}</p>
{{ formset.management_form }}
<div class="container radio-flex">
{% for category, forms in grouped_forms %}
<details open>
<summary
class="ak-category-header">{{ category.name }}</summary>
<div class="row row-cols-1 row-cols-lg-2">
{% for innerform in forms %}
{% bootstrap_form_errors innerform type='non_fields' %}
{% for field in innerform.hidden_fields %}
{{ field }}
{% endfor %}
<div class="container radio-group col">
<div class="container radio-info">
<details>
<summary class="ak-header">{{ innerform.preference.label }}
{% if innerform.ak_obj.reso %}
<span class="badge bg-dark rounded-pill"
title="{% trans 'Intends to submit a resolution' %}">{% fa6_icon "scroll" 'fas' %}</span>
{% endif %}
</summary>
<p>{{ innerform.ak_obj.description }}</p>
{% if innerform.ak_obj.owners_list %}
<p>{% trans "Responsible" %}: {{ innerform.ak_obj.owners_list }}</p>
{% endif %}
{% if show_types %}
<p>
{% for aktype in innerform.ak_obj.types.all %}
<span class="badge bg-light">{{ aktype.name }}</span>&nbsp;
{% endfor %}
</p>
{% endif %}
{% if innerform.ak_obj.reso %}
<!-- TODO: reso as an icon -->
<p><i>{% trans "Intends to submit a resolution" %}.</i></p>
{% endif %}
</details>
</div>
{% bootstrap_field innerform.preference show_label=False show_help=False field_class="pref-cls" %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}
</div>
</section>
{% endblock %}
<h2 class="text-warning mb-4 text-end">{% trans "Careful: after saving your preferences, you cannot edit them again!" %}</h2>
<button type="submit" class="save btn btn-primary float-end">
{% fa6_icon "check" 'fas' %} {% trans "Submit" %}
</button>
<button type="reset" class="btn btn-danger">
{% fa6_icon "undo-alt" 'fas' %} {% trans "Reset Form" %}
</button>
<a href="{% url 'dashboard:dashboard_event' slug=event.slug %}" class="btn btn-secondary">
{% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
</a>
</form>
{% endblock %}
{% block bottom_script %}
<script src="{% static 'common/vendor/chosen-js/chosen.jquery.js' %}"></script>
<script>
$(function () {
$('.chosen-select').chosen();
});
</script>
{% endblock %}
{% extends "base.html" %}
{% load fontawesome_6 %}
{% load i18n %}
{% block breadcrumbs %}
{% include "AKPreference/poll_breadcrumbs.html" %}
{% endblock %}
{% block footer_custom %}
{% if event.contact_email %}
<h4>
<a href="mailto:{{ event.contact_email }}">{% fa6_icon "envelope" 'fas' %} {% trans "Write to organizers of this event for questions and comments" %}</a>
</h4>
{% endif %}
{% endblock %}