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
  • renovate/beautifulsoup4-4.x
  • renovate/django-5.x
  • renovate/django-tex-1.x
  • renovate/jsonschema-4.x
  • renovate/djangorestframework-3.x
  • renovate/django-bootstrap5-25.x
  • renovate/django-debug-toolbar-6.x
  • main
  • koma/feature/preference-polling-form
9 results

Target

Select target project
No results found
Select Git revision
Loading items
Show changes
180 files
+ 8321
1557
Compare changes
  • Side-by-side
  • Inline

Files

+3 −4
Original line number Diff line number Diff line
image: python:3.9
image: python:3.11

services:
  - mysql
@@ -26,10 +26,9 @@ cache:
    - pip install pylint-gitlab pylint-django
    - mysql --version

check:
migrations:
  extends: .before_script_template
  script:
    - ./Utils/check.sh --all
    - source venv/bin/activate
    - ./manage.py makemigrations --dry-run --check

@@ -38,7 +37,7 @@ test:
  script:
    - source venv/bin/activate
    - echo "GRANT ALL on *.* to '${MYSQL_USER}';"| mysql -u root --password="${MYSQL_ROOT_PASSWORD}" -h mysql
    - pip install pytest-cov unittest-xml-reporting
    - pip install pytest-cov
    - coverage run --source='.' manage.py test --settings AKPlanning.settings_ci
  after_script:
    - source venv/bin/activate
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-01 17:28+0100\n"
"POT-Creation-Date: 2025-06-21 18: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"
@@ -61,28 +61,28 @@ msgstr "Veranstaltung"
msgid "Event this button belongs to"
msgstr "Veranstaltung, zu der dieser Button gehört"

#: AKDashboard/templates/AKDashboard/dashboard.html:18
#: AKDashboard/templates/AKDashboard/dashboard_event.html:29
#: AKDashboard/templates/AKDashboard/dashboard.html:19
#: AKDashboard/templates/AKDashboard/dashboard_event.html:30
#: AKDashboard/templates/AKDashboard/dashboard_row_old_event.html:53
msgid "Write to organizers of this event for questions and comments"
msgstr ""
"Kontaktiere die Organisator*innen des Events bei Fragen oder Kommentaren"

#: AKDashboard/templates/AKDashboard/dashboard.html:24
#: AKDashboard/templates/AKDashboard/dashboard.html:25
msgid "Old events"
msgstr "Frühere Veranstaltungen"

#: AKDashboard/templates/AKDashboard/dashboard.html:34
#: AKDashboard/templates/AKDashboard/dashboard.html:35
msgid "Currently, there are no Events!"
msgstr "Aktuell gibt es keine Events!"

#: AKDashboard/templates/AKDashboard/dashboard.html:37
#: AKDashboard/templates/AKDashboard/dashboard.html:38
msgid "Please contact an administrator if you want to use AKPlanning."
msgstr ""
"Bitte kontaktiere eine*n Administrator*in, falls du AKPlanning verwenden "
"möchtest."

#: AKDashboard/templates/AKDashboard/dashboard_event.html:19
#: AKDashboard/templates/AKDashboard/dashboard_event.html:20
msgid "Recent"
msgstr "Kürzlich"

@@ -113,22 +113,26 @@ msgstr "AK-Einreichung"
msgid "AK History"
msgstr "AK-Verlauf"

#: AKDashboard/views.py:69
#: AKDashboard/templates/AKDashboard/dashboard_row.html:72
msgid "AK Preferences"
msgstr "AK-Präferenzen"

#: AKDashboard/views.py:70
#, python-format
msgid "New AK: %(ak)s."
msgstr "Neuer AK: %(ak)s."

#: AKDashboard/views.py:72
#: AKDashboard/views.py:73
#, python-format
msgid "AK \"%(ak)s\" edited."
msgstr "AK \"%(ak)s\" bearbeitet."

#: AKDashboard/views.py:75
#: AKDashboard/views.py:76
#, python-format
msgid "AK \"%(ak)s\" deleted."
msgstr "AK \"%(ak)s\" gelöscht."

#: AKDashboard/views.py:90
#: AKDashboard/views.py:91
#, python-format
msgid "AK \"%(ak)s\" (re-)scheduled."
msgstr "AK \"%(ak)s\" (um-)geplant."
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@
{% endblock %}

{% block content %}
    {% include "messages.html" %}
    {% if total_event_count > 0 %}
        {% for event in active_and_current_events %}
            <div class="dashboard-row">
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@
{% endblock %}

{% block content %}
    {% include "messages.html" %}
    <div class="dashboard-row">
        {% include "AKDashboard/dashboard_row.html" %}

Original line number Diff line number Diff line
@@ -63,6 +63,17 @@
            <span class='text'>{% trans 'AK History' %}</span>
        </div>
    </a>
    {% if 'AKPreference'|check_app_installed and event.active %}
        {% if not event.poll_hidden or user.is_staff %}
            <a class="dashboard-box btn btn-primary"
            href="{% url 'poll:poll' event_slug=event.slug %}">
                <div class="col-sm-12 col-md-3 col-lg-2 dashboard-button">
                    <span class="fa fa-poll"></span>
                    <span class='text'>{% trans 'AK Preferences' %}</span>
                </div>
            </a>
        {% endif %}
    {% endif %}
    {% for button in event.dashboardbutton_set.all %}
        <a class="dashboard-box btn btn-{{ button.get_color_display }}"
           href="{{ button.url }}">
Original line number Diff line number Diff line
import zoneinfo

from django.apps import apps
from django.test import TestCase, override_settings
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils.timezone import now

from AKDashboard.models import DashboardButton
from AKModel.models import Event, AK, AKCategory
from AKModel.tests import BasicViewTests
from AKModel.models import AK, AKCategory, Event
from AKModel.tests.test_views import BasicViewTests


class DashboardTests(TestCase):
    """
    Specific Dashboard Tests
    """

    @classmethod
    def setUpTestData(cls):
        """
@@ -27,6 +29,7 @@ class DashboardTests(TestCase):
                end=now(),
                active=True,
                plan_hidden=False,
                poll_hidden=False,
        )
        cls.default_category = AKCategory.objects.create(
                name="Test Category",
@@ -62,7 +65,7 @@ class DashboardTests(TestCase):

        # History should be empty
        response = self.client.get(url)
        self.assertQuerysetEqual(response.context["recent_changes"], [])
        self.assertQuerySetEqual(response.context["recent_changes"], [])

        AK.objects.create(
                name="Test AK",
@@ -144,6 +147,26 @@ class DashboardTests(TestCase):
            self.assertContains(response, "Current AKs")
            self.assertContains(response, "AK Wall")

    def test_poll_hidden(self):
        """
        Test visibility of poll buttons with regard to poll visibility status for a given event
        """
        url_event_dashboard = reverse('dashboard:dashboard_event', kwargs={"slug": self.event.slug})

        if apps.is_installed('AKPreference'):
            # Poll hidden? No buttons should show up
            self.event.poll_hidden = True
            self.event.save()
            response = self.client.get(url_event_dashboard)
            self.assertNotContains(response, "AK Preferences")

            # Poll not hidden?
            # Buttons to preference poll should be on the page
            self.event.poll_hidden = False
            self.event.save()
            response = self.client.get(url_event_dashboard)
            self.assertContains(response, "AK Preferences")

    def test_dashboard_buttons(self):
        """
        Make sure manually added buttons are displayed correctly
+84 −8
Original line number Diff line number Diff line
@@ -20,7 +20,7 @@ from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, A
    ConstraintViolation, DefaultSlot, AKType
from AKModel.urls import get_admin_urls_event_wizard, get_admin_urls_event
from AKModel.views.ak import AKResetInterestView, AKResetInterestCounterView
from AKModel.views.manage import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView
from AKModel.views.manage import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView, ClearScheduleView


class EventRelatedFieldListFilter(RelatedFieldListFilter):
@@ -51,12 +51,16 @@ class EventAdmin(admin.ModelAdmin):
    wizard.
    """
    model = Event
    list_display = ['name', 'status_url', 'place', 'start', 'end', 'active', 'plan_hidden']
    list_display = ['name', 'status_url', 'place', 'start', 'end', 'active', 'plan_hidden', 'poll_hidden']
    list_filter = ['active']
    list_editable = ['active']
    ordering = ['-start']
    readonly_fields = ['status_url', 'plan_hidden', 'plan_published_at', 'toggle_plan_visibility']
    actions = ['publish', 'unpublish']
    readonly_fields = [
        'status_url',
        'plan_hidden', 'plan_published_at', 'toggle_plan_visibility',
        'poll_hidden', 'poll_published_at', 'toggle_poll_visibility',
    ]
    actions = ['publish_plan', 'unpublish_plan', 'publish_poll', 'unpublish_poll']

    def add_view(self, request, form_url='', extra_context=None):
        # Override
@@ -81,6 +85,10 @@ class EventAdmin(admin.ModelAdmin):
            from AKScheduling.urls import get_admin_urls_scheduling  # pylint: disable=import-outside-toplevel
            urls.extend(get_admin_urls_scheduling(self.admin_site))

        if apps.is_installed("AKSolverInterface"):
            from AKSolverInterface.urls import get_admin_urls_solver_interface  # pylint: disable=import-outside-toplevel
            urls.extend(get_admin_urls_solver_interface(self.admin_site))

        # Make sure built-in URLs are available as well
        urls.extend(super().get_urls())
        return urls
@@ -115,13 +123,31 @@ class EventAdmin(admin.ModelAdmin):
            text = _('Unpublish plan')
        return format_html("<a href='{url}'>{text}</a>", url=url, text=text)

    @display(description=_("Toggle poll visibility"))
    def toggle_poll_visibility(self, obj):
        """
        Define a read-only field to toggle the visibility of the preference poll of this event
        This will choose from two different link targets/views depending on the current visibility status

        :param obj: event to change the visibility of the poll for
        :return: toggling link (HTML)
        :rtype: str
        """
        if obj.poll_hidden:
            url = f"{reverse_lazy('admin:poll-publish')}?pks={obj.pk}"
            text = _('Publish preference poll')
        else:
            url = f"{reverse_lazy('admin:poll-unpublish')}?pks={obj.pk}"
            text = _('Unpublish preference poll')
        return format_html("<a href='{url}'>{text}</a>", url=url, text=text)

    def get_form(self, request, obj=None, change=False, **kwargs):
        # Override (update) form rendering to make sure the timezone of the event is used
        timezone.activate(obj.timezone)
        return super().get_form(request, obj, change, **kwargs)

    @action(description=_('Publish plan'))
    def publish(self, request, queryset):
    def publish_plan(self, request, queryset):
        """
        Admin action to publish the plan
        """
@@ -129,7 +155,7 @@ class EventAdmin(admin.ModelAdmin):
        return HttpResponseRedirect(f"{reverse_lazy('admin:plan-publish')}?pks={','.join(str(pk) for pk in selected)}")

    @action(description=_('Unpublish plan'))
    def unpublish(self, request, queryset):
    def unpublish_plan(self, request, queryset):
        """
        Admin action to hide the plan
        """
@@ -137,6 +163,23 @@ class EventAdmin(admin.ModelAdmin):
        return HttpResponseRedirect(
            f"{reverse_lazy('admin:plan-unpublish')}?pks={','.join(str(pk) for pk in selected)}")

    @action(description=_('Publish preference poll'))
    def publish_poll(self, request, queryset):
        """
        Admin action to publish the preference poll
        """
        selected = queryset.values_list('pk', flat=True)
        return HttpResponseRedirect(f"{reverse_lazy('admin:poll-publish')}?pks={','.join(str(pk) for pk in selected)}")

    @action(description=_('Unpublish preference poll'))
    def unpublish_poll(self, request, queryset):
        """
        Admin action to hide the preference poll
        """
        selected = queryset.values_list('pk', flat=True)
        return HttpResponseRedirect(
            f"{reverse_lazy('admin:poll-unpublish')}?pks={','.join(str(pk) for pk in selected)}")


class PrepopulateWithNextActiveEventMixin:
    """
@@ -286,7 +329,8 @@ class AKAdmin(PrepopulateWithNextActiveEventMixin, SimpleHistoryAdmin):
    list_filter = ['event',
                   WishFilter,
                   ('category', EventRelatedFieldListFilter),
                   ('requirements', EventRelatedFieldListFilter)
                   ('requirements', EventRelatedFieldListFilter),
                   ('types', EventRelatedFieldListFilter),
                   ]
    list_editable = ['short_name', 'track', 'interest_counter']
    ordering = ['pk']
@@ -436,10 +480,12 @@ class AKSlotAdmin(EventTimezoneFormMixin, PrepopulateWithNextActiveEventMixin, a
    """
    model = AKSlot
    list_display = ['id', 'ak', 'room', 'start', 'duration', 'event']
    list_filter = ['event', ('room', EventRelatedFieldListFilter)]
    list_filter = ['event', "fixed", ('room', EventRelatedFieldListFilter),
                   ('ak__category', EventRelatedFieldListFilter)]
    ordering = ['start']
    readonly_fields = ['ak_details_link', 'updated']
    form = AKSlotAdminForm
    actions = ["reset_scheduling"]

    @display(description=_('AK Details'))
    def ak_details_link(self, akslot):
@@ -455,6 +501,36 @@ class AKSlotAdmin(EventTimezoneFormMixin, PrepopulateWithNextActiveEventMixin, a
            return mark_safe(str(link))
        return "-"

    def get_urls(self):
        """
        Add additional URLs/views
        """
        urls = [
            path('clear-schedule/', ClearScheduleView.as_view(), name="clear-schedule"),
        ]
        urls.extend(super().get_urls())
        return urls

    @action(description=_("Clear start/rooms"))
    def reset_scheduling(self, request, queryset):
        """
        Action: Reset start and room field for the given AKs
        Will use a typical admin confirmation view flow
        """
        if queryset.filter(fixed=True).exists():
            self.message_user(
                request,
                _(
                        "Cannot reset scheduling for fixed AKs. "
                        "Please make sure to filter out fixed AKs first."
                ),
                messages.ERROR,
            )
            return redirect('admin:AKModel_akslot_changelist')
        selected = queryset.values_list('pk', flat=True)
        return HttpResponseRedirect(
            f"{reverse_lazy('admin:clear-schedule')}?pks={','.join(str(pk) for pk in selected)}")

    ak_details_link.short_description = _('AK Details')


Original line number Diff line number Diff line
@@ -12,7 +12,7 @@ from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _

from AKModel.availability.models import Availability
from AKModel.availability.serializers import AvailabilitySerializer
from AKModel.availability.serializers import AvailabilityFormSerializer
from AKModel.models import Event


@@ -41,22 +41,11 @@ class AvailabilitiesFormMixin(forms.Form):
        :rtype: str
        """
        if instance:
            availabilities = AvailabilitySerializer(
                instance.availabilities.all(), many=True
            ).data
            availabilities = instance.availabilities.all()
        else:
            availabilities = []

        return json.dumps(
            {
                'availabilities': availabilities,
                'event': {
                    # 'timezone': event.timezone,
                    'date_from': str(event.start),
                    'date_to': str(event.end),
                },
            }
        )
        return json.dumps(AvailabilityFormSerializer((availabilities, event)).data)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
@@ -65,9 +54,10 @@ class AvailabilitiesFormMixin(forms.Form):
        if isinstance(self.event, int):
            self.event = Event.objects.get(pk=self.event)
        initial = kwargs.pop('initial', {})
        initial['availabilities'] = self._serialize(self.event, kwargs['instance'])
        if 'availabilities' not in initial:
            initial['availabilities'] = self._serialize(self.event, kwargs.get('instance'))
        if not isinstance(self, forms.BaseModelForm):
            kwargs.pop('instance')
            kwargs.pop('instance', None)
        kwargs['initial'] = initial

    def _parse_availabilities_json(self, jsonavailabilities):
@@ -183,7 +173,7 @@ class AvailabilitiesFormMixin(forms.Form):
        for avail in availabilities:
            setattr(avail, reference_name, instance.id)

    def _replace_availabilities(self, instance, availabilities: [Availability]):
    def _replace_availabilities(self, instance, availabilities: list[Availability]):
        """
        Replace the existing list of availabilities belonging to an entity with a new, updated one

Original line number Diff line number Diff line
@@ -11,6 +11,8 @@ from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _

from AKModel.models import Event, AKOwner, Room, AK, AKCategory
# TODO: Decouple from AKPreference app
from AKPreference.models import EventParticipant

zero_time = datetime.time(0, 0)

@@ -24,6 +26,7 @@ zero_time = datetime.time(0, 0)
# enable availabilities for AKs and AKCategories
# add verbose names and help texts to model attributes
# adapt or extemd documentation
# add participants


class Availability(models.Model):
@@ -79,20 +82,48 @@ class Availability(models.Model):
        verbose_name=_('AK Category'),
        help_text=_('AK Category whose availability this is'),
    )
    participant = models.ForeignKey(
        to=EventParticipant,
        related_name='availabilities',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        verbose_name=_('Participant'),
        help_text=_('Participant whose availability this is'),
    )
    start = models.DateTimeField()
    end = models.DateTimeField()

    def __str__(self) -> str:
        person = self.person.name if self.person else None
        participant = str(self.participant) if self.participant else None
        room = getattr(self.room, 'name', None)
        event = getattr(getattr(self, 'event', None), 'name', None)
        ak = getattr(self.ak, 'name', None)
        ak_category = getattr(self.ak_category, 'name', None)
        return f'Availability(event={event}, person={person}, room={room}, ak={ak}, ak category={ak_category})'
        arg_list = [
            f"event={event}",
            f"person={person}",
            f"room={room}",
            f"ak={ak}",
            f"ak category={ak_category}",
            f"participant={participant}",
        ]
        return f'Availability({", ".join(arg_list)})'

    def __hash__(self):
        return hash(
            (getattr(self, 'event', None), self.person, self.room, self.ak, self.ak_category, self.start, self.end))
            (
                getattr(self, 'event', None),
                self.person,
                self.room,
                self.ak,
                self.ak_category,
                self.participant,
                self.start,
                self.end,
            )
        )

    def __eq__(self, other: 'Availability') -> bool:
        """Comparisons like ``availability1 == availability2``.
@@ -103,7 +134,7 @@ class Availability(models.Model):
        return all(
            (
                getattr(self, attribute, None) == getattr(other, attribute, None)
                for attribute in ['event', 'person', 'room', 'ak', 'ak_category', 'start', 'end']
                for attribute in ['event', 'person', 'room', 'ak', 'ak_category', 'participant', 'start', 'end']
            )
        )

@@ -151,9 +182,12 @@ class Availability(models.Model):
        if not other.overlaps(self, strict=False):
            raise Exception('Only overlapping Availabilities can be merged.')

        return Availability(
        avail = Availability(
            start=min(self.start, other.start), end=max(self.end, other.end)
        )
        if self.event == other.event:
            avail.event = self.event
        return avail

    def __or__(self, other: 'Availability') -> 'Availability':
        """Performs the merge operation: ``availability1 | availability2``"""
@@ -168,9 +202,12 @@ class Availability(models.Model):
        if not other.overlaps(self, False):
            raise Exception('Only overlapping Availabilities can be intersected.')

        return Availability(
        avail = Availability(
            start=max(self.start, other.start), end=min(self.end, other.end)
        )
        if self.event == other.event:
            avail.event = self.event
        return avail

    def __and__(self, other: 'Availability') -> 'Availability':
        """Performs the intersect operation: ``availability1 &
@@ -247,7 +284,15 @@ class Availability(models.Model):
                f'{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}')

    @classmethod
    def with_event_length(cls, event, person=None, room=None, ak=None, ak_category=None):
    def with_event_length(
        cls,
        event: Event,
        person: AKOwner | None = None,
        room: Room | None = None,
        ak: AK | None = None,
        ak_category: AKCategory | None = None,
        participant: EventParticipant | None = None,
    ) -> "Availability":
        """
        Create an availability covering exactly the time between event start and event end.
        Can e.g., be used to create default availabilities.
@@ -265,7 +310,31 @@ class Availability(models.Model):
        timeframe_end = event.end  # adapt to our event model
        timeframe_end = timeframe_end + datetime.timedelta(days=1)
        return Availability(start=timeframe_start, end=timeframe_end, event=event, person=person,
                                    room=room, ak=ak, ak_category=ak_category)
                                    room=room, ak=ak, ak_category=ak_category, participant=participant)

    def is_covered(self, availabilities: List['Availability']):
        """Check if list of availibilities cover this object.

        :param availabilities: availabilities to check.
        :return: whether the availabilities cover full event.
        :rtype: bool
        """
        avail_union = Availability.union(availabilities)
        return any(avail.contains(self) for avail in avail_union)

    @classmethod
    def is_event_covered(cls, event: Event, availabilities: List['Availability']) -> bool:
        """Check if list of availibilities cover whole event.

        :param event: event to check.
        :param availabilities: availabilities to check.
        :return: whether the availabilities cover full event.
        :rtype: bool
        """
        # NOTE: Cannot use `Availability.with_event_length` as its end is the
        #       event end + 1 day
        full_event = Availability(event=event, start=event.start, end=event.end)
        return full_event.is_covered(availabilities)

    class Meta:
        verbose_name = _('Availability')
Original line number Diff line number Diff line
@@ -4,9 +4,10 @@
# Documentation was mainly added by us, other changes are marked in the code
from django.utils import timezone
from rest_framework.fields import SerializerMethodField
from rest_framework.serializers import ModelSerializer
from rest_framework.serializers import BaseSerializer, ModelSerializer

from AKModel.availability.models import Availability
from AKModel.models import Event


class AvailabilitySerializer(ModelSerializer):
@@ -44,3 +45,28 @@ class AvailabilitySerializer(ModelSerializer):
    class Meta:
        model = Availability
        fields = ('id', 'start', 'end', 'allDay')


class AvailabilityFormSerializer(BaseSerializer):
    """Serializer to configure an availability form."""

    def create(self, validated_data):
        raise ValueError("`AvailabilityFormSerializer` is read-only.")

    def to_internal_value(self, data):
        raise ValueError("`AvailabilityFormSerializer` is read-only.")

    def update(self, instance, validated_data):
        raise ValueError("`AvailabilityFormSerializer` is read-only.")

    def to_representation(self, instance: tuple[Availability, Event], **kwargs):
        availabilities, event = instance

        return {
            'availabilities': AvailabilitySerializer(availabilities, many=True).data,
            'event': {
                # 'timezone': event.timezone,
                'date_from': str(event.start),
                'date_to': str(event.end),
            },
        }
Original line number Diff line number Diff line
@@ -93,7 +93,7 @@
    "model": "AKModel.akcategory",
    "pk": 1,
    "fields": {
        "name": "Spa",
        "name": "Spaß",
        "color": "275246",
        "description": "",
        "present_by_default": true,
@@ -115,7 +115,7 @@
    "model": "AKModel.akcategory",
    "pk": 3,
    "fields": {
        "name": "Spa/Kultur",
        "name": "Spaß/Kultur",
        "color": "333333",
        "description": "",
        "present_by_default": true,
@@ -198,7 +198,8 @@
    "pk": 1,
    "fields": {
        "name": "Input",
        "event": 2
        "event": 2,
        "slug": "input"
    }
},
{
@@ -361,7 +362,9 @@
    "fields": {
        "name": "Test AK Inhalt",
        "short_name": "test1",
        "description": "",
        "description": "-",
        "goal": "-",
        "info": "",
        "link": "",
        "protocol_link": "",
        "category": 4,
@@ -389,7 +392,9 @@
    "fields": {
        "name": "Test AK Meta",
        "short_name": "test2",
        "description": "",
        "description": "-",
        "goal": "-",
        "info": "",
        "link": "",
        "protocol_link": "",
        "category": 5,
@@ -415,6 +420,8 @@
        "name": "AK Wish",
        "short_name": "wish1",
        "description": "Description of my Wish",
        "goal": "-",
        "info": "",
        "link": "",
        "protocol_link": "",
        "category": 3,
@@ -437,6 +444,66 @@
        ]
    }
},
{
    "model": "AKModel.ak",
    "pk": 4,
    "fields": {
        "name": "Test AK fixed slots",
        "short_name": "testfixed",
        "description": "-",
        "goal": "-",
        "info": "",
        "link": "",
        "protocol_link": "",
        "category": 4,
        "track": null,
        "reso": false,
        "present": true,
        "notes": "",
        "interest": -1,
        "interest_counter": 0,
        "include_in_export": false,
        "event": 2,
        "owners": [
            1
        ],
        "requirements": [
            3
        ],
        "conflicts": [],
        "prerequisites": []
    }
},
{
    "model": "AKModel.ak",
    "pk": 5,
    "fields": {
        "name": "Test AK Ernst",
        "short_name": "testernst",
        "description": "-",
        "goal": "-",
        "info": "",
        "link": "",
        "protocol_link": "",
        "category": 2,
        "track": null,
        "reso": false,
        "present": true,
        "notes": "",
        "interest": -1,
        "interest_counter": 0,
        "include_in_export": false,
        "event": 1,
        "owners": [
            3
        ],
        "requirements": [
            2
        ],
        "conflicts": [],
        "prerequisites": []
    }
},
{
    "model": "AKModel.room",
    "pk": 1,
@@ -461,6 +528,19 @@
        "properties": []
    }
},
{
    "model": "AKModel.room",
    "pk": 3,
    "fields": {
        "name": "BBB Session 1",
        "location": "",
        "capacity": -1,
        "event": 1,
        "properties": [
            2
        ]
    }
},
{
    "model": "AKModel.akslot",
    "pk": 1,
@@ -526,6 +606,58 @@
        "updated": "2022-12-02T12:23:11.856Z"
    }
},
{
    "model": "AKModel.akslot",
    "pk": 6,
    "fields": {
        "ak": 4,
        "room": null,
        "start": "2020-11-08T18:30:00Z",
        "duration": "2.00",
        "fixed": true,
        "event": 2,
        "updated": "2022-12-02T12:23:11.856Z"
    }
},
{
    "model": "AKModel.akslot",
    "pk": 7,
    "fields": {
        "ak": 4,
        "room": 2,
        "start": null,
        "duration": "2.00",
        "fixed": true,
        "event": 2,
        "updated": "2022-12-02T12:23:11.856Z"
    }
},
{
    "model": "AKModel.akslot",
    "pk": 8,
    "fields": {
        "ak": 4,
        "room": 2,
        "start": "2020-11-07T16:00:00Z",
        "duration": "2.00",
        "fixed": true,
        "event": 2,
        "updated": "2022-12-02T12:23:11.856Z"
    }
},
{
    "model": "AKModel.akslot",
    "pk": 9,
    "fields": {
        "ak": 5,
        "room": null,
        "start": null,
        "duration": "2.00",
        "fixed": false,
        "event": 1,
        "updated": "2022-12-02T12:23:11.856Z"
    }
},
{
    "model": "AKModel.constraintviolation",
    "pk": 1,
@@ -669,5 +801,71 @@
        "start": "2020-11-07T18:30:00Z",
        "end": "2020-11-07T21:30:00Z"
    }
},
{
    "model": "AKModel.availability",
    "pk": 7,
    "fields": {
        "event": 1,
        "person": null,
        "room": null,
        "ak": 5,
        "ak_category": null,
        "start": "2020-10-01T17:41:22Z",
        "end": "2020-10-04T17:41:30Z"
    }
},
{
    "model": "AKModel.availability",
    "pk": 8,
    "fields": {
        "event": 1,
        "person": null,
        "room": 3,
        "ak": null,
        "ak_category": null,
        "start": "2020-10-01T17:41:22Z",
        "end": "2020-10-04T17:41:30Z"
    }
},
{
    "model": "AKModel.defaultslot",
    "pk": 1,
    "fields": {
        "event": 2,
        "start": "2020-11-07T08:00:00Z",
        "end": "2020-11-07T12:00:00Z",
        "primary_categories": [5]
    }
},
{
    "model": "AKModel.defaultslot",
    "pk": 2,
    "fields": {
        "event": 2,
        "start": "2020-11-07T14:00:00Z",
        "end": "2020-11-07T17:00:00Z",
        "primary_categories": [4]
    }
},
{
    "model": "AKModel.defaultslot",
    "pk": 3,
    "fields": {
        "event": 2,
        "start": "2020-11-08T08:00:00Z",
        "end": "2020-11-08T19:00:00Z",
        "primary_categories": [4, 5]
    }
},
{
    "model": "AKModel.defaultslot",
    "pk": 4,
    "fields": {
        "event": 2,
        "start": "2020-11-09T17:00:00Z",
        "end": "2020-11-10T01:00:00Z",
        "primary_categories": [4, 5, 3]
    }
}
]
+16 −2
Original line number Diff line number Diff line
@@ -34,9 +34,10 @@ class NewEventWizardStartForm(forms.ModelForm):
    """
    class Meta:
        model = Event
        fields = ['name', 'slug', 'timezone', 'plan_hidden']
        fields = ['name', 'slug', 'timezone', 'plan_hidden', 'poll_hidden']
        widgets = {
            'plan_hidden': forms.HiddenInput(),
            'poll_hidden': forms.HiddenInput(),
        }

    # Special hidden field for wizard state handling
@@ -53,7 +54,7 @@ class NewEventWizardSettingsForm(forms.ModelForm):
    class Meta:
        model = Event
        fields = "__all__"
        exclude = ['plan_published_at']
        exclude = ['plan_published_at', 'poll_published_at']
        widgets = {
            'name': forms.HiddenInput(),
            'slug': forms.HiddenInput(),
@@ -65,6 +66,7 @@ class NewEventWizardSettingsForm(forms.ModelForm):
            'interest_end': DateTimeInput(),
            'reso_deadline': DateTimeInput(),
            'plan_hidden': forms.HiddenInput(),
            'poll_hidden': forms.HiddenInput(),
        }


@@ -173,6 +175,18 @@ class SlideExportForm(AdminIntermediateForm):
        initial=3,
        label=_("# next AKs"),
        help_text=_("How many next AKs should be shown on a slide?"))
    types = forms.MultipleChoiceField(
        label=_("AK Types"),
        help_text=_("Which AK types should be included in the slides?"),
        widget=forms.CheckboxSelectMultiple,
        choices=[],
        required=False)
    types_all_selected_only = forms.BooleanField(
        initial=False,
        label=_("Only show AKs with all selected types?"),
        help_text=_("If checked, only AKs that have all selected types will be shown in the slides. "
                    "If unchecked, AKs with at least one of the selected types will be shown."),
        required=False)
    presentation_mode = forms.TypedChoiceField(
        initial=False,
        label=_("Presentation only?"),
Original line number Diff line number Diff line
# 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'),
        ),
    ]
Original line number Diff line number Diff line
# 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",
            ),
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 4.2.13 on 2025-02-10 10:23

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

    dependencies = [
        ("AKModel", "0064_event_export_slot"),
    ]

    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",
                    ),
                ),
            ],
            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, "Prefer"),
                            (2, "Strong prefer"),
                            (3, "Required"),
                        ],
                        default=0,
                        help_text="Preference level for the AK",
                        verbose_name="Preference",
                    ),
                ),
                (
                    "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="AKModel.eventparticipant",
                        verbose_name="Participant",
                    ),
                ),
            ],
            options={
                "verbose_name": "AK Preference",
                "verbose_name_plural": "AK Preferences",
            },
        ),
        migrations.AddField(
            model_name="availability",
            name="participant",
            field=models.ForeignKey(
                blank=True,
                help_text="Participant whose availability this is",
                null=True,
                on_delete=django.db.models.deletion.CASCADE,
                related_name="availabilities",
                to="AKModel.eventparticipant",
                verbose_name="Participant",
            ),
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 4.2.13 on 2025-02-10 22:31

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

    dependencies = [
        ("AKModel", "0065_eventparticipant_akpreference_and_more"),
    ]

    operations = [
        migrations.AddField(
            model_name="akpreference",
            name="slot",
            field=models.ForeignKey(
                default=None,
                help_text="AKSlot this preference belongs to",
                on_delete=django.db.models.deletion.CASCADE,
                to="AKModel.akslot",
                verbose_name="AKSlot",
            ),
            preserve_default=False,
        ),
        migrations.AlterUniqueTogether(
            name="akpreference",
            unique_together={("event", "participant", "slot")},
        ),
        migrations.RemoveField(
            model_name="akpreference",
            name="ak",
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 4.2.13 on 2025-02-11 00:23

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

    dependencies = [
        (
            "AKModel",
            "0066_akpreference_slot_alter_akpreference_unique_together_and_more",
        ),
    ]

    operations = [
        migrations.AddField(
            model_name="eventparticipant",
            name="requirements",
            field=models.ManyToManyField(
                blank=True,
                help_text="Participant's Requirements",
                to="AKModel.akrequirement",
                verbose_name="Requirements",
            ),
        ),
        migrations.AlterField(
            model_name="akpreference",
            name="slot",
            field=models.ForeignKey(
                help_text="AK Slot this preference belongs to",
                on_delete=django.db.models.deletion.CASCADE,
                to="AKModel.akslot",
                verbose_name="AK Slot",
            ),
        ),
    ]
+43 −0
Original line number Diff line number Diff line
# Generated by Django 5.1.6 on 2025-05-11 15:21

from django.db import migrations, models


def create_slugs(apps, schema_editor):
    """
    Automatically generate slugs from existing type names
    """
    AKType = apps.get_model("AKModel", "AKType")
    for ak_type in AKType.objects.all():
        slug = ak_type.name.lower().split(" ")[0]
        ak_type.slug = slug[:30] if len(slug) > 30 else slug
        ak_type.save()


class Migration(migrations.Migration):

    dependencies = [
        ('AKModel', '0067_eventparticipant_requirements_and_more'),
    ]

    operations = [
        migrations.AddField(
            model_name='aktype',
            name='slug',
            field=models.SlugField(max_length=30, null=True, verbose_name='Slug'),
        ),
        migrations.RunPython(create_slugs, migrations.RunPython.noop),
        migrations.AlterUniqueTogether(
            name='aktype',
            unique_together={('event', 'name')},
        ),
        migrations.AlterField(
            model_name='aktype',
            name='slug',
            field=models.SlugField(max_length=30, verbose_name='Slug'),
        ),
        migrations.AlterUniqueTogether(
            name='aktype',
            unique_together={('event', 'name'), ('event', 'slug')},
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 5.2.1 on 2025-06-17 15:57

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('AKModel', '0067_eventparticipant_requirements_and_more'),
        ('AKPreference', '0001_initial'),
    ]

    operations = [
        migrations.RemoveField(
            model_name='eventparticipant',
            name='event',
        ),
        migrations.RemoveField(
            model_name='eventparticipant',
            name='requirements',
        ),
        migrations.RemoveField(
            model_name='availability',
            name='participant',
        ),
        migrations.AddField(
            model_name='event',
            name='poll_hidden',
            field=models.BooleanField(default=True, help_text='Hides preference poll for non-staff users', verbose_name='Poll Hidden'),
        ),
        migrations.AddField(
            model_name='event',
            name='poll_published_at',
            field=models.DateTimeField(blank=True, help_text='Timestamp at which the preference poll was published', null=True, verbose_name='Poll published at'),
        ),
        migrations.DeleteModel(
            name='AKPreference',
        ),
        migrations.DeleteModel(
            name='EventParticipant',
        ),
        migrations.AddField(
            model_name='availability',
            name='participant',
            field=models.ForeignKey(blank=True, help_text='Participant whose availability this is', null=True,
                                    on_delete=django.db.models.deletion.CASCADE, related_name='availabilities',
                                    to='AKPreference.eventparticipant', verbose_name='Participant'),
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 5.2.1 on 2025-06-17 18:44

from django.db import migrations


class Migration(migrations.Migration):

    dependencies = [
        ('AKModel', '0068_aktype_slug'),
        ('AKModel', '0068_event_export_delete_preferences_participants'),
    ]

    operations = [
    ]
Original line number Diff line number Diff line
# Generated by Django 5.2.3 on 2025-06-18 10:47

from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('AKModel', '0069_merge_20250617_1844'),
    ]

    operations = [
        migrations.AddField(
            model_name='akrequirement',
            name='relevant_for_participants',
            field=models.BooleanField(default=False, help_text='Show this requirement when collecting participant preferences', verbose_name='Relevant for Participants?'),
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 5.2.3 on 2025-06-21 18:58

from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [
        ('AKModel', '0070_requirements_relevant_for_participants'),
    ]

    operations = [
        migrations.AlterField(
                model_name='event',
                name='slug',
                field=models.SlugField(help_text='Short name of letters/numbers/dashes/underscores used in URLs.',
                                       max_length=32, unique=True, verbose_name='Short Form'),
        ),
    ]
Original line number Diff line number Diff line
# Generated by Django 5.2.3 on 2025-06-23 12:54

from django.db import migrations, models


class Migration(migrations.Migration):
    dependencies = [
        ('AKModel', '0071_alter_event_slug'),
    ]

    operations = [
        migrations.AddField(
                model_name='ak',
                name='goal',
                field=models.CharField(default='-',
                                       help_text='Design of the AK (e.g. discussion, problem solving, brainstorming, input, output) and/or goal (e.g. Reso, solution, collection of ideas, document, exchange) in a few words',
                                       max_length=512, verbose_name='Design/Goal'),
                preserve_default=False,
        ),
        migrations.AddField(
                model_name='ak',
                name='info',
                field=models.TextField(blank=True,
                                       help_text='Anything else about the AK, e.g. links, stories, detailed description',
                                       verbose_name='Further Information'),
        ),
        migrations.AddField(
                model_name='historicalak',
                name='goal',
                field=models.CharField(default='-',
                                       help_text='Design of the AK (e.g. discussion, problem solving, brainstorming, input, output) and/or goal (e.g. Reso, solution, collection of ideas, document, exchange) in a few words',
                                       max_length=512, verbose_name='Design/Goal'),
                preserve_default=False,
        ),
        migrations.AddField(
                model_name='historicalak',
                name='info',
                field=models.TextField(blank=True,
                                       help_text='Anything else about the AK, e.g. links, stories, detailed description',
                                       verbose_name='Further Information'),
        ),
        migrations.AlterField(
                model_name='ak',
                name='description',
                field=models.TextField(help_text='1-2 sentences of AK description', verbose_name='Brief Description'),
        ),
        migrations.AlterField(
                model_name='historicalak',
                name='description',
                field=models.TextField(help_text='1-2 sentences of AK description', verbose_name='Brief Description'),
        ),
    ]
+573 −39

File changed.

Preview size limit exceeded, changes collapsed.

Original line number Diff line number Diff line
from rest_framework import serializers

from AKModel.models import AK, Room, AKSlot, AKTrack, AKCategory, AKOwner
from AKModel.models import AK, AKCategory, AKOwner, AKSlot, AKTrack, Room


class StringListField(serializers.ListField):
    """List field containing strings."""

    child = serializers.CharField()


class IntListField(serializers.ListField):
    """List field containing integers."""

    child = serializers.IntegerField()


class AKOwnerSerializer(serializers.ModelSerializer):
Original line number Diff line number Diff line
@@ -19,6 +19,10 @@

    \faUser~ {{ translations.who }}

    {% if show_types %}
        \faList~ {{ translations.types }}
    {% endif %}

    \faClock~ {{ translations.duration }}

    \faScroll~{{ translations.reso }}
@@ -45,6 +49,10 @@

                \faUser~ {{ ak.owners_list | latex_escape }}

                {% if show_types %}
                    \faList~ {{ak.types_list }}
                {% endif %}

                {% if not result_presentation_mode %}
                    \faClock~ {{ak.durations_list}}
                {% endif %}
Original line number Diff line number Diff line
@@ -5,10 +5,21 @@ from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.contrib.messages.storage.base import Message
from django.test import TestCase
from django.urls import reverse_lazy, reverse

from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \
    ConstraintViolation, DefaultSlot
from django.urls import reverse, reverse_lazy

from AKModel.models import (
    AK,
    AKCategory,
    AKOrgaMessage,
    AKOwner,
    AKRequirement,
    AKSlot,
    AKTrack,
    ConstraintViolation,
    DefaultSlot,
    Event,
    Room,
)


class BasicViewTests:
@@ -29,9 +40,10 @@ class BasicViewTests:
    since the test framework does not understand the concept of abstract test definitions and would handle this class
    as real test case otherwise, distorting the test results.
    """

    # pylint: disable=no-member
    VIEWS = []
    APP_NAME = ''
    APP_NAME = ""
    VIEWS_STAFF_ONLY = []
    EDIT_TESTCASES = []

@@ -41,16 +53,26 @@ class BasicViewTests:
        """
        user_model = get_user_model()
        self.staff_user = user_model.objects.create(
            username='Test Staff User', email='teststaff@example.com', password='staffpw',
            is_staff=True, is_active=True
            username="Test Staff User",
            email="teststaff@example.com",
            password="staffpw",
            is_staff=True,
            is_active=True,
        )
        self.admin_user = user_model.objects.create(
            username='Test Admin User', email='testadmin@example.com', password='adminpw',
            is_staff=True, is_superuser=True, is_active=True
            username="Test Admin User",
            email="testadmin@example.com",
            password="adminpw",
            is_staff=True,
            is_superuser=True,
            is_active=True,
        )
        self.deactivated_user = user_model.objects.create(
            username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw',
            is_staff=True, is_active=False
            username="Test Deactivated User",
            email="testdeactivated@example.com",
            password="deactivatedpw",
            is_staff=True,
            is_active=False,
        )

    def _name_and_url(self, view_name):
@@ -62,7 +84,9 @@ class BasicViewTests:
        :return: full view name with prefix if applicable, url of the view
        :rtype: str, str
        """
        view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
        view_name_with_prefix = (
            f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
        )
        url = reverse(view_name_with_prefix, kwargs=view_name[1])
        return view_name_with_prefix, url

@@ -95,10 +119,16 @@ class BasicViewTests:
            view_name_with_prefix, url = self._name_and_url(view_name)
            try:
                response = self.client.get(url)
                self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken")
                self.assertEqual(
                    response.status_code,
                    200,
                    msg=f"{view_name_with_prefix} ({url}) broken",
                )
            except Exception:  # pylint: disable=broad-exception-caught
                self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
                          f"\n\n{traceback.format_exc()}")
                self.fail(
                    f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
                    f"\n\n{traceback.format_exc()}"
                )

    def test_access_control_staff_only(self):
        """
@@ -107,11 +137,16 @@ class BasicViewTests:
        # Not logged in? Views should not be visible
        self.client.logout()
        for view_name_info in self.VIEWS_STAFF_ONLY:
            expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2]
            expected_response_code = (
                302 if len(view_name_info) == 2 else view_name_info[2]
            )
            view_name_with_prefix, url = self._name_and_url(view_name_info)
            response = self.client.get(url)
            self.assertEqual(response.status_code, expected_response_code,
                             msg=f"{view_name_with_prefix} ({url}) accessible by non-staff")
            self.assertEqual(
                response.status_code,
                expected_response_code,
                msg=f"{view_name_with_prefix} ({url}) accessible by non-staff",
            )

        # Logged in? Views should be visible
        self.client.force_login(self.staff_user)
@@ -119,20 +154,30 @@ class BasicViewTests:
            view_name_with_prefix, url = self._name_and_url(view_name_info)
            try:
                response = self.client.get(url)
                self.assertEqual(response.status_code, 200,
                                 msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)")
                self.assertEqual(
                    response.status_code,
                    200,
                    msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)",
                )
            except Exception:  # pylint: disable=broad-exception-caught
                self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
                          f"\n\n{traceback.format_exc()}")
                self.fail(
                    f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
                    f"\n\n{traceback.format_exc()}"
                )

        # Disabled user? Views should not be visible
        self.client.force_login(self.deactivated_user)
        for view_name_info in self.VIEWS_STAFF_ONLY:
            expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2]
            expected_response_code = (
                302 if len(view_name_info) == 2 else view_name_info[2]
            )
            view_name_with_prefix, url = self._name_and_url(view_name_info)
            response = self.client.get(url)
            self.assertEqual(response.status_code, expected_response_code,
                             msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user")
            self.assertEqual(
                response.status_code,
                expected_response_code,
                msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user",
            )

    def _to_sendable_value(self, val):
        """
@@ -182,16 +227,26 @@ class BasicViewTests:
            self.client.logout()

        response = self.client.get(url)
        self.assertEqual(response.status_code, 200, msg=f"{name}: Could not load edit form via GET ({url})")
        self.assertEqual(
            response.status_code,
            200,
            msg=f"{name}: Could not load edit form via GET ({url})",
        )

        form = response.context[form_name]
        data = {k: self._to_sendable_value(v) for k, v in form.initial.items()}

        response = self.client.post(url, data=data)
        if expected_code == 200:
            self.assertEqual(response.status_code, 200, msg=f"{name}: Did not return 200 ({url}")
            self.assertEqual(
                response.status_code, 200, msg=f"{name}: Did not return 200 ({url}"
            )
        elif expected_code == 302:
            self.assertRedirects(response, target_url, msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}")
            self.assertRedirects(
                response,
                target_url,
                msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}",
            )
        if expected_message != "":
            self._assert_message(response, expected_message, msg_prefix=f"{name}")

@@ -200,30 +255,42 @@ class ModelViewTests(BasicViewTests, TestCase):
    """
    Basic view test cases for views from AKModel plus some custom tests
    """
    fixtures = ['model.json']

    fixtures = ["model.json"]

    ADMIN_MODELS = [
        (Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'),
        (AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'),
        (AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'),
        (DefaultSlot, 'defaultslot')
        (Event, "event"),
        (AKOwner, "akowner"),
        (AKCategory, "akcategory"),
        (AKTrack, "aktrack"),
        (AKRequirement, "akrequirement"),
        (AK, "ak"),
        (Room, "room"),
        (AKSlot, "akslot"),
        (AKOrgaMessage, "akorgamessage"),
        (ConstraintViolation, "constraintviolation"),
        (DefaultSlot, "defaultslot"),
    ]

    VIEWS_STAFF_ONLY = [
        ('admin:index', {}),
        ('admin:event_status', {'event_slug': 'kif42'}),
        ('admin:event_requirement_overview', {'event_slug': 'kif42'}),
        ('admin:ak_csv_export', {'event_slug': 'kif42'}),
        ('admin:ak_wiki_export', {'slug': 'kif42'}),
        ('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}),
        ('admin:ak_slide_export', {'event_slug': 'kif42'}),
        ('admin:default-slots-editor', {'event_slug': 'kif42'}),
        ('admin:room-import', {'event_slug': 'kif42'}),
        ('admin:new_event_wizard_start', {}),
        ("admin:index", {}),
        ("admin:event_status", {"event_slug": "kif42"}),
        ("admin:event_requirement_overview", {"event_slug": "kif42"}),
        ("admin:ak_csv_export", {"event_slug": "kif42"}),
        ("admin:ak_wiki_export", {"slug": "kif42"}),
        ("admin:ak_delete_orga_messages", {"event_slug": "kif42"}),
        ("admin:ak_slide_export", {"event_slug": "kif42"}),
        ("admin:default-slots-editor", {"event_slug": "kif42"}),
        ("admin:room-import", {"event_slug": "kif42"}),
        ("admin:new_event_wizard_start", {}),
    ]

    EDIT_TESTCASES = [
        {'view': 'admin:default-slots-editor', 'kwargs': {'event_slug': 'kif42'}, "admin": True},
        {
            "view": "admin:default-slots-editor",
            "kwargs": {"event_slug": "kif42"},
            "admin": True,
        },
    ]

    def test_admin(self):
@@ -234,24 +301,32 @@ class ModelViewTests(BasicViewTests, TestCase):
        for model in self.ADMIN_MODELS:
            # Special treatment for a subset of views (where we exchanged default functionality, e.g., create views)
            if model[1] == "event":
                _, url = self._name_and_url(('admin:new_event_wizard_start', {}))
                _, url = self._name_and_url(("admin:new_event_wizard_start", {}))
            elif model[1] == "room":
                _, url = self._name_and_url(('admin:room-new', {}))
                _, url = self._name_and_url(("admin:room-new", {}))
            # Otherwise, just call the creation form view
            else:
                _, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {}))
                _, url = self._name_and_url((f"admin:AKModel_{model[1]}_add", {}))
            response = self.client.get(url)
            self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken")
            self.assertEqual(
                response.status_code,
                200,
                msg=f"Add form for model {model[1]} ({url}) broken",
            )

        for model in self.ADMIN_MODELS:
            # Test the update view using the first existing instance of each model
            m = model[0].objects.first()
            if m is not None:
                _, url = self._name_and_url(
                    (f'admin:AKModel_{model[1]}_change', {'object_id': m.pk})
                    (f"admin:AKModel_{model[1]}_change", {"object_id": m.pk})
                )
                response = self.client.get(url)
                self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken")
                self.assertEqual(
                    response.status_code,
                    200,
                    msg=f"Edit form for model {model[1]} ({url}) broken",
                )

    def test_wiki_export(self):
        """
@@ -260,17 +335,27 @@ class ModelViewTests(BasicViewTests, TestCase):
        """
        self.client.force_login(self.admin_user)

        export_url = reverse_lazy("admin:ak_wiki_export", kwargs={'slug': 'kif42'})
        export_url = reverse_lazy("admin:ak_wiki_export", kwargs={"slug": "kif42"})
        response = self.client.get(export_url)
        self.assertEqual(response.status_code, 200, "Export not working at all")

        export_count = 0
        for _, aks in response.context["categories_with_aks"]:
            for ak in aks:
                self.assertEqual(ak.include_in_export, True,
                                 f"AK with export flag set to False (pk={ak.pk}) included in export")
                self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export")
                self.assertEqual(
                    ak.include_in_export,
                    True,
                    f"AK with export flag set to False (pk={ak.pk}) included in export",
                )
                self.assertNotEqual(
                    ak.pk,
                    1,
                    "AK known to be excluded from export (PK 1) included in export",
                )
                export_count += 1

        self.assertEqual(export_count, AK.objects.filter(event_id=2, include_in_export=True).count(),
                         "Wiki export contained the wrong number of AKs")
        self.assertEqual(
            export_count,
            AK.objects.filter(event_id=2, include_in_export=True).count(),
            "Wiki export contained the wrong number of AKs",
        )
+15 −6
Original line number Diff line number Diff line
@@ -4,11 +4,11 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter

import AKModel.views.api
from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \
    AKsByUserView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
    NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView
from AKModel.views.ak import AKCSVExportView, AKMessageDeleteView, AKRequirementOverview, AKWikiExportView
from AKModel.views.event_wizard import NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardImportView, \
    NewEventWizardPrepareImportView, NewEventWizardSettingsView, NewEventWizardStartView
from AKModel.views.manage import AKsByUserView, DefaultSlotEditorView, ExportSlidesView, PlanPublishView, \
    PlanUnpublishView, PollPublishView, PollUnpublishView
from AKModel.views.room import RoomBatchCreationView
from AKModel.views.status import EventStatusView

@@ -44,6 +44,11 @@ if apps.is_installed("AKSubmission"):
    from AKSubmission.api import increment_interest_counter
    extra_paths.append(path('api/ak/<pk>/indicate-interest/', increment_interest_counter, name='submission-ak-indicate-interest'))

# If AKSolverInterface is active, register additional API endpoints
if apps.is_installed("AKSolverInterface"):
     from AKSolverInterface.api import ExportEventForSolverViewSet
     api_router.register("solver-export", ExportEventForSolverViewSet, basename="solver-export")

event_specific_paths = [
    path('api/', include(api_router.urls), name='api'),
]
@@ -68,7 +73,9 @@ def get_admin_urls_event_wizard(admin_site):
    return [
        path('add/wizard/start/', admin_site.admin_view(NewEventWizardStartView.as_view()),
             name="new_event_wizard_start"),
        path('add/wizard/settings/', csp_update(FONT_SRC=["maxcdn.bootstrapcdn.com"], SCRIPT_SRC=["cdnjs.cloudflare.com"], STYLE_SRC=["cdnjs.cloudflare.com"])(admin_site.admin_view(NewEventWizardSettingsView.as_view())),
        path('add/wizard/settings/', csp_update(
                {"font-src": ["maxcdn.bootstrapcdn.com"], "script-src": ["cdnjs.cloudflare.com"],
                 "style-src": ["cdnjs.cloudflare.com"]})(admin_site.admin_view(NewEventWizardSettingsView.as_view())),
             name="new_event_wizard_settings"),
        path('add/wizard/created/<slug:event_slug>/', admin_site.admin_view(NewEventWizardPrepareImportView.as_view()),
             name="new_event_wizard_prepare_import"),
@@ -103,6 +110,8 @@ def get_admin_urls_event(admin_site):
        path('<slug:event_slug>/ak-slide-export/', admin_site.admin_view(ExportSlidesView.as_view()), name="ak_slide_export"),
        path('plan/publish/', admin_site.admin_view(PlanPublishView.as_view()), name="plan-publish"),
        path('plan/unpublish/', admin_site.admin_view(PlanUnpublishView.as_view()), name="plan-unpublish"),
        path('poll/publish/', admin_site.admin_view(PollPublishView.as_view()), name="poll-publish"),
        path('poll/unpublish/', admin_site.admin_view(PollUnpublishView.as_view()), name="poll-unpublish"),
        path('<slug:event_slug>/defaultSlots/', admin_site.admin_view(DefaultSlotEditorView.as_view()),
             name="default-slots-editor"),
        path('<slug:event_slug>/importRooms/', admin_site.admin_view(RoomBatchCreationView.as_view()),
Original line number Diff line number Diff line
@@ -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, DetailView
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, AdminViewMixin
from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner
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
Original line number Diff line number Diff line
@@ -152,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


Original line number Diff line number Diff line
@@ -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"
Original line number Diff line number Diff line
@@ -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;">
+1 −1
Original line number Diff line number Diff line
from django.test import TestCase

from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests


class PlanViewTests(BasicViewTests, TestCase):
+9 −8
Original line number Diff line number Diff line
from csp.decorators import csp_replace
from django.urls import path, include
from django.urls import include, path

from . import views

@@ -10,7 +10,8 @@ urlpatterns = [
            '<slug:event_slug>/plan/',
            include([
                path('', views.PlanIndexView.as_view(), name='plan_overview'),
            path('wall/', csp_replace(FRAME_ANCESTORS="*")(views.PlanScreenView.as_view()), name='plan_wall'),
                path('wall/', csp_replace({"frame-ancestors": ("*",)})(views.PlanScreenView.as_view()),
                     name='plan_wall'),
                path('room/<int:pk>/', views.PlanRoomView.as_view(), name='plan_room'),
                path('track/<int:pk>/', views.PlanTrackView.as_view(), name='plan_track'),
            ])
+115 −23
Original line number Diff line number Diff line
from datetime import timedelta
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):
@@ -20,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)
@@ -39,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)
@@ -61,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):
    """
@@ -96,12 +189,20 @@ 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
        akslots = super().get_queryset().filter(start__gt=self.start)
        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
        for akslot in akslots.all():
        context = super().get_context_data(object_list=object_list, **kwargs)
        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
@@ -113,15 +214,6 @@ class PlanScreenView(PlanIndexView):
        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)
        return akslots

    def get_context_data(self, *, object_list=None, **kwargs):
        context = super().get_context_data(object_list=object_list, **kwargs)
        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


class PlanRoomView(FilterByEventSlugMixin, DetailView):
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-16 16:30+0200\n"
"POT-Creation-Date: 2025-06-21 18: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"
@@ -17,10 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"

#: AKPlanning/settings.py:148
#: AKPlanning/settings.py:152
msgid "German"
msgstr "Deutsch"

#: AKPlanning/settings.py:149
#: AKPlanning/settings.py:153
msgid "English"
msgstr "Englisch"
Original line number Diff line number Diff line
@@ -10,11 +10,13 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.2/ref/settings/
"""

import decimal
import os

from csp.constants import SELF
from django.utils.translation import gettext_lazy as _
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
from split_settings.tools import optional, include
from split_settings.tools import include, optional

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

@@ -38,6 +40,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',
@@ -55,6 +59,7 @@ INSTALLED_APPS = [
    'django_tex',
    'compressor',
    'docs',
    "csp",
]

MIDDLEWARE = [
@@ -171,7 +176,7 @@ STATICFILES_FINDERS = (
# Settings for Bootstrap
BOOTSTRAP5 = {
    "javascript_url": {
        "url": STATIC_URL + "common/vendor/bootstrap/bootstrap-5.0.2.bundle.min.js",
        "url": STATIC_URL + "common/vendor/bootstrap/bootstrap-5.3.7.bundle.min.js",
    },
}

@@ -221,17 +226,28 @@ DASHBOARD_RECENT_MAX = 25
# (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/"
LOGIN_REDIRECT_URL = SIMPLE_BACKEND_REDIRECT_URL

# Content Security Policy
CSP_DEFAULT_SRC = ("'self'",)
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'", )
CSP_FONT_SRC = ("'self'", "data:", "fonts.gstatic.com")
CONTENT_SECURITY_POLICY = {
    "EXCLUDE_URL_PREFIXES": ["/admin"],
    "DIRECTIVES": {
        "default-src": ("'self'",),
        "script-src": ("'self'", "'unsafe-inline'", "'unsafe-eval'"),
        "img-src": ("'self'", "data:"),
        "style-src": ("'self'", "'unsafe-inline'", "fonts.googleapis.com"),
        "font-src": ("'self'", "data:", "fonts.gstatic.com"),
        "frame-src": ("'self'",),
    },
}

# Emails
SEND_MAILS = True
Original line number Diff line number Diff line
@@ -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')))

AKPreference/admin.py

0 → 100644
+49 −0
Original line number Diff line number Diff line
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']

AKPreference/apps.py

0 → 100644
+9 −0
Original line number Diff line number Diff line
from django.apps import AppConfig


class AkpreferenceConfig(AppConfig):
    """
    App configuration (default, only specifies name of the app)
    """

    name = "AKPreference"

AKPreference/forms.py

0 → 100644
+51 −0
Original line number Diff line number Diff line
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
Original line number Diff line number Diff line
# 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-21 16:10+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:86
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:100
msgid "Careful: after saving your preferences, you cannot edit them again!"
msgstr "Achtung: Nach dem Abschicken kannst du deine Präferenzen nicht noch einmal editieren!"

#: AKPreference/templates/AKPreference/poll.html:103
msgid "Submit"
msgstr "Eintragen"

#: AKPreference/templates/AKPreference/poll.html:107
msgid "Reset Form"
msgstr "Formular leeren"

#: AKPreference/templates/AKPreference/poll.html:111
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:139
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."

AKPreference/models.py

0 → 100644
+126 −0

File added.

Preview size limit exceeded, changes collapsed.

Original line number Diff line number Diff line
{% 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 %}

AKPreference/tests.py

0 → 100644
+40 −0

File added.

Preview size limit exceeded, changes collapsed.

AKPreference/urls.py

0 → 100644
+16 −0

File added.

Preview size limit exceeded, changes collapsed.

AKPreference/views.py

0 → 100644
+144 −0

File added.

Preview size limit exceeded, changes collapsed.

File changed.

Preview size limit exceeded, changes collapsed.

File changed.

Preview size limit exceeded, changes collapsed.

+40 −0

File added.

Preview size limit exceeded, changes collapsed.

File changed.

Preview size limit exceeded, changes collapsed.

File changed.

Preview size limit exceeded, changes collapsed.