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
  • feature-type-filters
  • komasolver
  • main
  • renovate/django-5.x
  • renovate/django_csp-4.x
  • renovate/jsonschema-4.x
  • renovate/uwsgi-2.x
7 results

Target

Select target project
  • konstantin/akplanning
  • matedealer/akplanning
  • kif/akplanning
  • mirco/akplanning
  • lordofthevoid/akplanning
  • voidptr/akplanning
  • xayomer/akplanning-fork
  • mollux/akplanning
  • neumantm/akplanning
  • mmarx/akplanning
  • nerf/akplanning
  • felix_bonn/akplanning
  • sebastian.uschmann/akplanning
13 results
Select Git revision
  • 520-akowner
  • 520-fix-event-wizard-datepicker
  • 520-fix-scheduling
  • 520-improve-scheduling
  • 520-improve-scheduling-2
  • 520-improve-submission
  • 520-improve-trackmanager
  • 520-improve-wall
  • 520-message-resolved
  • 520-status
  • 520-upgrades
  • add_express_interest_to_ak_overview
  • admin-production-color
  • bugfixes
  • csp
  • featire-ical-export
  • feature-ak-requirement-lists
  • feature-akslide-export-better-filename
  • feature-akslides
  • feature-better-admin
  • feature-better-cv-list
  • feature-colors
  • feature-constraint-checking
  • feature-constraint-checking-wip
  • feature-dashboard-history-button
  • feature-event-status
  • feature-event-wizard
  • feature-export-flag
  • feature-improve-admin
  • feature-improve-filters
  • feature-improved-user-creation-workflow
  • feature-interest-view
  • feature-mails
  • feature-modular-status
  • feature-plan-autoreload
  • feature-present-default
  • feature-register-link
  • feature-remaining-constraint-validation
  • feature-room-import
  • feature-scheduler-improve
  • feature-scheduling-2.0
  • feature-special-attention
  • feature-time-input
  • feature-tracker
  • feature-wiki-wishes
  • feature-wish-slots
  • feature-wizard-buttons
  • features-availabilities
  • fix-ak-times-above-folg
  • fix-api
  • fix-constraint-violation-string
  • fix-cv-checking
  • fix-default-slot-length
  • fix-default-slot-localization
  • fix-doc-minor
  • fix-duration-display
  • fix-event-tz-pytz-update
  • fix-history-interest
  • fix-interest-view
  • fix-js
  • fix-pipeline
  • fix-plan-timezone-now
  • fix-room-add
  • fix-scheduling-drag
  • fix-slot-defaultlength
  • fix-timezone
  • fix-translation-scheduling
  • fix-virtual-room-admin
  • fix-wizard-csp
  • font-locally
  • improve-admin
  • improve-online
  • improve-slides
  • improve-submission-coupling
  • interest_restriction
  • main
  • master
  • meta-debug-toolbar
  • meta-export
  • meta-makemessages
  • meta-performance
  • meta-tests
  • meta-tests-gitlab-test
  • meta-upgrades
  • mollux-master-patch-02906
  • port-availabilites-fullcalendar
  • qs
  • remove-tags
  • renovate/configure
  • renovate/django-4.x
  • renovate/django-5.x
  • renovate/django-bootstrap-datepicker-plus-5.x
  • renovate/django-bootstrap5-23.x
  • renovate/django-bootstrap5-24.x
  • renovate/django-compressor-4.x
  • renovate/django-debug-toolbar-4.x
  • renovate/django-registration-redux-2.x
  • renovate/django-simple-history-3.x
  • renovate/django-split-settings-1.x
  • renovate/django-timezone-field-5.x
100 results
Show changes
Showing
with 1790 additions and 254 deletions
import json
import math
from collections import defaultdict
from collections.abc import Iterable
from datetime import datetime, timedelta
from itertools import chain
from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from jsonschema.exceptions import best_match
from AKModel.availability.models import Availability
from AKModel.models import (
AK,
AKCategory,
AKOwner,
AKPreference,
AKSlot,
DefaultSlot,
Event,
EventParticipant,
Room,
)
from AKSolverInterface.utils import construct_schema_validator
class JSONExportTest(TestCase):
"""Test if JSON export is correct.
It tests if the output conforms to the KoMa specification:
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
"""
fixtures = ["model.json"]
@classmethod
def setUpTestData(cls):
"""Shared set up by initializing admin user."""
cls.admin_user = get_user_model().objects.create(
username="Test Admin User",
email="testadmin@example.com",
password="adminpw",
is_staff=True,
is_superuser=True,
is_active=True,
)
cls.json_export_validator = construct_schema_validator(
"solver-input-export.schema.json"
)
def setUp(self):
self.client.force_login(self.admin_user)
self.export_dict = {}
self.export_objects = {
"aks": {},
"rooms": {},
"participants": {},
}
self.ak_slots: Iterable[AKSlot] = []
self.rooms: Iterable[Room] = []
self.participants: Iterable[EventParticipant] = []
self.owners: Iterable[AKOwner] = []
self.slots_in_an_hour: float = 1.0
self.max_participant_pk = 0
self.event: Event | None = None
def set_up_event(self, event: Event) -> None:
"""Set up by retrieving json export and initializing data."""
export_url = reverse("admin:ak_json_export", kwargs={"event_slug": event.slug})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200, "Export not working at all")
soup = BeautifulSoup(response.content, features="lxml")
self.export_dict = json.loads(soup.find("pre").string)
self.export_objects["aks"] = {ak["id"]: ak for ak in self.export_dict["aks"]}
self.export_objects["rooms"] = {
room["id"]: room for room in self.export_dict["rooms"]
}
self.export_objects["participants"] = {
participant["id"]: participant
for participant in self.export_dict["participants"]
}
self.ak_slots = (
AKSlot.objects.filter(event__slug=event.slug)
.select_related("ak")
.prefetch_related("ak__conflicts")
.prefetch_related("ak__prerequisites")
.all()
)
self.rooms = Room.objects.filter(event__slug=event.slug).all()
self.participants = EventParticipant.objects.filter(
event__slug=event.slug
).all()
self.owners = AKOwner.objects.filter(event__slug=event.slug).all()
self.max_participant_pk = (
self.participants.latest("pk").pk if self.participants else 0
)
self.slots_in_an_hour = 1 / self.export_dict["timeslots"]["info"]["duration"]
self.event = event
def test_all_aks_exported(self):
"""Test if exported AKs match AKSlots of Event."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
self.assertEqual(
{slot.pk for slot in self.ak_slots},
self.export_objects["aks"].keys(),
"Exported AKs does not match the AKSlots of the event",
)
def _check_uniqueness(self, lst, name: str, key: str | None = "id"):
if key is not None:
lst = [entry[key] for entry in lst]
self.assertEqual(len(lst), len(set(lst)), f"{name} IDs not unique!")
def _check_type(self, attr, cls, name: str, item: str) -> None:
self.assertTrue(isinstance(attr, cls), f"{item} {name} not a {cls}")
def _check_lst(
self, lst: list[str], name: str, item: str, contained_type=str
) -> None:
self.assertTrue(isinstance(lst, list), f"{item} {name} not a list")
self.assertTrue(
all(isinstance(c, contained_type) for c in lst),
f"{item} has non-{contained_type} {name}",
)
if contained_type in {str, int}:
self._check_uniqueness(lst, name, key=None)
def test_conformity_to_schema(self):
"""Test if JSON structure and types conform to schema."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
error = best_match(
self.json_export_validator.iter_errors(self.export_dict)
)
msg = "" if not error else f"{error.message} at {error.json_path}"
self.assertFalse(error, msg)
def test_id_uniqueness(self):
"""Test if objects are only exported once."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
self._check_uniqueness(self.export_dict["aks"], "AKs")
self._check_uniqueness(self.export_dict["rooms"], "Rooms")
self._check_uniqueness(self.export_dict["participants"], "Participants")
self._check_uniqueness(
chain.from_iterable(self.export_dict["timeslots"]["blocks"]),
"Timeslots",
)
def test_timeslot_ids_consecutive(self):
"""Test if Timeslots ids are chronologically consecutive."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
prev_id = None
for timeslot in chain.from_iterable(
self.export_dict["timeslots"]["blocks"]
):
if prev_id is not None:
self.assertLess(
prev_id,
timeslot["id"],
"timeslot ids must be increasing",
)
prev_id = timeslot["id"]
def test_general_conformity_to_spec(self):
"""Test if rest of JSON structure and types conform to standard."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
info_keys = {"title": "name", "slug": "slug"}
for attr in ["contact_email", "place"]:
if hasattr(self.event, attr) and getattr(self.event, attr):
info_keys[attr] = attr
self.assertEqual(
self.export_dict["info"].keys(),
info_keys.keys(),
"info keys not as expected",
)
for attr, attr_field in info_keys.items():
self.assertEqual(
getattr(self.event, attr_field), self.export_dict["info"][attr]
)
def test_ak_durations(self):
"""Test if all AK durations are correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
self.assertLessEqual(
float(slot.duration) * self.slots_in_an_hour - 1e-4,
ak["duration"],
"Slot duration is too short",
)
self.assertEqual(
math.ceil(float(slot.duration) * self.slots_in_an_hour - 1e-4),
ak["duration"],
"Slot duration is wrong",
)
self.assertEqual(
float(slot.duration),
ak["info"]["duration_in_hours"],
"Slot duration_in_hours is wrong",
)
def test_ak_conflicts(self):
"""Test if all AK conflicts are correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
conflict_slots = set(
self.ak_slots.filter(
ak__in=slot.ak.conflicts.all()
).values_list("pk", flat=True)
)
other_ak_slots = (
self.ak_slots.filter(ak=slot.ak)
.exclude(pk=slot.pk)
.values_list("pk", flat=True)
)
conflict_slots.update(other_ak_slots)
self.assertEqual(
conflict_slots,
set(ak["properties"]["conflicts"]),
f"Conflicts for slot {slot.pk} not as expected",
)
def test_ak_depenedencies(self):
"""Test if all AK dependencies are correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
dependency_slots = self.ak_slots.filter(
ak__in=slot.ak.prerequisites.all()
).values_list("pk", flat=True)
self.assertEqual(
set(dependency_slots),
set(ak["properties"]["dependencies"]),
f"Dependencies for slot {slot.pk} not as expected",
)
def test_ak_reso(self):
"""Test if resolution intent of AKs is correctly exported."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
self.assertEqual(slot.ak.reso, ak["info"]["reso"])
self.assertEqual(
slot.ak.reso, "resolution" in ak["time_constraints"]
)
def test_ak_info(self):
"""Test if contents of AK info dict is correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
self.assertEqual(ak["info"]["name"], slot.ak.name)
self.assertEqual(
ak["info"]["head"], ", ".join(map(str, slot.ak.owners.all()))
)
self.assertEqual(ak["info"]["description"], slot.ak.description)
self.assertEqual(ak["info"]["django_ak_id"], slot.ak.pk)
self.assertEqual(
ak["info"]["types"],
list(slot.ak.types.values_list("name", flat=True).order_by()),
)
def test_ak_room_constraints(self):
"""Test if AK room constraints are exported as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
ak = self.export_objects["aks"][slot.pk]
requirements = list(
slot.ak.requirements.values_list("name", flat=True)
)
# proxy rooms
if not any(constr.startswith("proxy") for constr in requirements):
requirements.append("no-proxy")
# fixed slot
if slot.fixed and slot.room is not None:
requirements.append(f"fixed-room-{slot.room.pk}")
self.assertEqual(
set(ak["room_constraints"]),
set(requirements),
f"Room constraints for slot {slot.pk} not as expected",
)
def test_ak_time_constraints(self):
"""Test if AK time constraints are exported as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for slot in self.ak_slots:
time_constraints = set()
# add time constraints for AK category
if slot.ak.category:
category_constraints = AKCategory.create_category_optimizer_constraints(
[slot.ak.category]
)
time_constraints |= category_constraints
if slot.fixed and slot.start is not None:
# fixed slot
time_constraints.add(f"fixed-akslot-{slot.pk}")
elif not Availability.is_event_covered(
slot.event, slot.ak.availabilities.all()
):
# restricted AK availability
time_constraints.add(f"availability-ak-{slot.ak.pk}")
for owner in slot.ak.owners.all():
# restricted owner availability
if not owner.availabilities.all():
# no availability for owner -> assume full event is covered
continue
if not Availability.is_event_covered(
slot.event, owner.availabilities.all()
):
time_constraints.add(f"availability-person-{owner.pk}")
ak = self.export_objects["aks"][slot.pk]
self.assertEqual(
set(ak["time_constraints"]),
time_constraints,
f"Time constraints for slot {slot.pk} not as expected",
)
def test_all_rooms_exported(self):
"""Test if exported Rooms match the rooms of Event."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
self.assertEqual(
{room.pk for room in self.rooms},
self.export_objects["rooms"].keys(),
"Exported Rooms do not match the Rooms of the event",
)
def test_room_capacity(self):
"""Test if room capacity is exported correctly."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for room in self.rooms:
export_room = self.export_objects["rooms"][room.pk]
self.assertEqual(room.capacity, export_room["capacity"])
def test_room_info(self):
"""Test if contents of Room info dict is correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for room in self.rooms:
export_room = self.export_objects["rooms"][room.pk]
self.assertEqual(room.name, export_room["info"]["name"])
def test_room_timeconstraints(self):
"""Test if Room time constraints are exported as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for room in self.rooms:
time_constraints = set()
# test if time availability of room is restricted
if not Availability.is_event_covered(
event, room.availabilities.all()
):
time_constraints.add(f"availability-room-{room.pk}")
export_room = self.export_objects["rooms"][room.pk]
self.assertEqual(
time_constraints, set(export_room["time_constraints"])
)
def test_room_fulfilledroomconstraints(self):
"""Test if room constraints fulfilled by Room are correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for room in self.rooms:
# room properties
fulfilled_room_constraints = set(
room.properties.values_list("name", flat=True)
)
# proxy rooms
if not any(
constr.startswith("proxy")
for constr in fulfilled_room_constraints
):
fulfilled_room_constraints.add("no-proxy")
fulfilled_room_constraints.add(f"fixed-room-{room.pk}")
export_room = self.export_objects["rooms"][room.pk]
self.assertEqual(
fulfilled_room_constraints,
set(export_room["fulfilled_room_constraints"]),
)
def _get_timeslot_start_end(self, timeslot):
start = datetime.strptime(timeslot["info"]["start"], "%Y-%m-%d %H:%M").replace(
tzinfo=self.event.timezone
)
end = datetime.strptime(timeslot["info"]["end"], "%Y-%m-%d %H:%M").replace(
tzinfo=self.event.timezone
)
return start, end
def _get_cat_availability_in_export(self):
export_slot_cat_avails = defaultdict(list)
for timeslot in chain.from_iterable(self.export_dict["timeslots"]["blocks"]):
for constr in timeslot["fulfilled_time_constraints"]:
if constr.startswith("availability-cat-"):
cat_name = constr[len("availability-cat-") :]
start, end = self._get_timeslot_start_end(timeslot)
export_slot_cat_avails[cat_name].append(
Availability(event=self.event, start=start, end=end)
)
return {
cat_name: Availability.union(avail_lst)
for cat_name, avail_lst in export_slot_cat_avails.items()
}
def _get_cat_availability(self):
if DefaultSlot.objects.filter(event=self.event).exists():
# Event has default slots -> use them for category availability
default_slots_avails = defaultdict(list)
for def_slot in DefaultSlot.objects.filter(event=self.event).all():
avail = Availability(
event=self.event,
start=def_slot.start.astimezone(self.event.timezone),
end=def_slot.end.astimezone(self.event.timezone),
)
for cat in def_slot.primary_categories.all():
default_slots_avails[cat.name].append(avail)
return {
cat_name: Availability.union(avail_lst)
for cat_name, avail_lst in default_slots_avails.items()
}
# Event has no default slots -> all categories available through whole event
start = self.event.start.astimezone(self.event.timezone)
end = self.event.end.astimezone(self.event.timezone)
delta = (end - start).total_seconds()
# tweak event end
# 1. shorten event to match discrete slot grid
slot_seconds = 3600 / self.slots_in_an_hour
remainder_seconds = delta % slot_seconds
remainder_seconds += 1 # add a second to compensate rounding errs
end -= timedelta(seconds=remainder_seconds)
# set seconds and microseconds to 0 as they are not exported to the json
start -= timedelta(seconds=start.second, microseconds=start.microsecond)
end -= timedelta(seconds=end.second, microseconds=end.microsecond)
event_avail = Availability(event=self.event, start=start, end=end)
category_names = AKCategory.objects.filter(event=self.event).values_list(
"name", flat=True
)
return {cat_name: [event_avail] for cat_name in category_names}
def test_timeslots_consecutive(self):
"""Test if consecutive timeslots in JSON are in fact consecutive."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
prev_end = None
for timeslot in chain.from_iterable(
self.export_dict["timeslots"]["blocks"]
):
start, end = self._get_timeslot_start_end(timeslot)
self.assertLess(start, end)
delta = end - start
self.assertAlmostEqual(
delta.total_seconds() / (3600), 1 / self.slots_in_an_hour
)
if prev_end is not None:
self.assertLessEqual(prev_end, start)
prev_end = end
def test_block_cover_categories(self):
"""Test if blocks covers all default slot resp. whole event per category."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
category_names = AKCategory.objects.filter(event=event).values_list(
"name", flat=True
)
export_cat_avails = self._get_cat_availability_in_export()
cat_avails = self._get_cat_availability()
for cat_name in category_names:
for avail in cat_avails[cat_name]:
# check that all category availabilities are covered
self.assertTrue(
avail.is_covered(export_cat_avails[cat_name]),
f"AKCategory {cat_name}: avail ({avail.start} - {avail.end}) "
f"not covered by {[f'({a.start} - {a.end})' for a in export_cat_avails[cat_name]]}",
)
def _is_restricted_and_contained_slot(
self, slot: Availability, availabilities: list[Availability]
) -> bool:
"""Test if object is not available for whole event and may happen during slot."""
return slot.is_covered(availabilities) and not Availability.is_event_covered(
self.event, availabilities
)
def _is_ak_fixed_in_slot(
self,
ak_slot: AKSlot,
timeslot_avail: Availability,
) -> bool:
if not ak_slot.fixed or ak_slot.start is None:
return False
ak_slot_avail = Availability(
event=self.event,
start=ak_slot.start.astimezone(self.event.timezone),
end=ak_slot.end.astimezone(self.event.timezone),
)
return timeslot_avail.overlaps(ak_slot_avail, strict=True)
def test_timeslot_fulfilledconstraints(self):
"""Test if fulfilled time constraints by timeslot are as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
cat_avails = self._get_cat_availability()
num_blocks = len(self.export_dict["timeslots"]["blocks"])
for block_idx, block in enumerate(
self.export_dict["timeslots"]["blocks"]
):
for timeslot in block:
start, end = self._get_timeslot_start_end(timeslot)
timeslot_avail = Availability(
event=self.event, start=start, end=end
)
fulfilled_time_constraints = set()
# reso deadline
if self.event.reso_deadline is not None:
# timeslot ends before deadline
if end < self.event.reso_deadline.astimezone(
self.event.timezone
):
fulfilled_time_constraints.add("resolution")
# add category constraints
fulfilled_time_constraints |= (
AKCategory.create_category_optimizer_constraints(
[
cat
for cat in AKCategory.objects.filter(
event=self.event
).all()
if timeslot_avail.is_covered(cat_avails[cat.name])
]
)
)
# add owner constraints
fulfilled_time_constraints |= {
f"availability-person-{owner.id}"
for owner in self.owners
if self._is_restricted_and_contained_slot(
timeslot_avail,
Availability.union(owner.availabilities.all()),
)
}
# add room constraints
fulfilled_time_constraints |= {
f"availability-room-{room.id}"
for room in self.rooms
if self._is_restricted_and_contained_slot(
timeslot_avail,
Availability.union(room.availabilities.all()),
)
}
# add participant constraints
fulfilled_time_constraints |= {
f"availability-participant-{participant.id}"
for participant in self.participants
if self._is_restricted_and_contained_slot(
timeslot_avail,
Availability.union(participant.availabilities.all()),
)
}
# add ak constraints
fulfilled_time_constraints |= {
f"availability-ak-{ak.id}"
for ak in AK.objects.filter(event=event)
if self._is_restricted_and_contained_slot(
timeslot_avail,
Availability.union(ak.availabilities.all()),
)
}
fulfilled_time_constraints |= {
f"fixed-akslot-{slot.id}"
for slot in self.ak_slots
if self._is_ak_fixed_in_slot(slot, timeslot_avail)
}
fulfilled_time_constraints |= {
f"notblock{idx}"
for idx in range(num_blocks)
if idx != block_idx
}
self.assertEqual(
fulfilled_time_constraints,
set(timeslot["fulfilled_time_constraints"]),
)
def test_timeslots_info(self):
"""Test timeslots info dict"""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
self.assertAlmostEqual(
self.export_dict["timeslots"]["info"]["duration"],
float(self.event.export_slot),
)
block_names = []
for block in self.export_dict["timeslots"]["blocks"]:
if not block:
continue
block_start, _ = self._get_timeslot_start_end(block[0])
_, block_end = self._get_timeslot_start_end(block[-1])
start_day = block_start.strftime("%A, %d. %b")
if block_start.date() == block_end.date():
# same day
time_str = (
block_start.strftime("%H:%M")
+ " - "
+ block_end.strftime("%H:%M")
)
else:
# different days
time_str = (
block_start.strftime("%a %H:%M")
+ " - "
+ block_end.strftime("%a %H:%M")
)
block_names.append([start_day, time_str])
self.assertEqual(
block_names, self.export_dict["timeslots"]["info"]["blocknames"]
)
def _owner_has_ak(self, owner: AKOwner) -> bool:
owned_aks = self.ak_slots.filter(ak__owners=owner).all()
return bool(owned_aks)
def test_all_participants_exported(self):
"""Test if exported Rooms match the rooms of Event."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
participant_ids = set(self.participants.values_list("pk", flat=True))
for idx, owner in enumerate(self.owners, self.max_participant_pk + 1):
if self._owner_has_ak(owner):
participant_ids.add(idx)
self.assertEqual(
participant_ids,
self.export_objects["participants"].keys(),
"Exported Participants does not match the Participants of the event",
)
def test_participant_info(self):
"""Test if contents of participants info dict is correct."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for participant in self.participants:
export_participant = self.export_objects["participants"][
participant.pk
]
self.assertEqual(
str(participant), export_participant["info"]["name"]
)
for idx, owner in enumerate(self.owners, self.max_participant_pk + 1):
if not self._owner_has_ak(owner):
continue
export_participant = self.export_objects["participants"][idx]
self.assertEqual(
str(owner) + " [AKOwner]", export_participant["info"]["name"]
)
def test_participant_timeconstraints(self):
"""Test if participant time constraints are exported as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for participant in self.participants:
export_participant = self.export_objects["participants"][
participant.pk
]
time_constraints = set()
participant_avails = participant.availabilities.all()
if participant_avails and not Availability.is_event_covered(
self.event, participant_avails
):
# participant has restricted availability
if AKPreference.objects.filter(
event=self.event,
participant=participant,
preference=AKPreference.PreferenceLevel.REQUIRED,
):
# partipant is actually required for AKs
time_constraints.add(
f"availability-participant-{participant.pk}"
)
self.assertEqual(
set(export_participant["time_constraints"]), time_constraints
)
# dummy participants have no time constraints
for idx, owner in enumerate(self.owners, self.max_participant_pk + 1):
if not self._owner_has_ak(owner):
continue
export_participant = self.export_objects["participants"][idx]
self.assertEqual(export_participant["time_constraints"], [])
def test_participant_roomconstraints(self):
"""Test if participant room constraints are exported as expected."""
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for participant in self.participants:
export_participant = self.export_objects["participants"][
participant.pk
]
room_constraints = [
constr.name for constr in participant.requirements.all()
]
self.assertCountEqual(
export_participant["room_constraints"], room_constraints
)
for idx, owner in enumerate(self.owners, self.max_participant_pk + 1):
if not self._owner_has_ak(owner):
continue
export_participant = self.export_objects["participants"][idx]
self.assertEqual(export_participant["room_constraints"], [])
def test_preferences(self):
"""Test if preferences are exported as expected."""
def _preference_json(pref: AKPreference):
return {
"ak_id": pref.slot.pk,
"required": pref.preference == AKPreference.PreferenceLevel.REQUIRED,
"preference_score": (
pref.preference
if pref.preference != AKPreference.PreferenceLevel.REQUIRED
else -1
),
}
for event in Event.objects.all():
with self.subTest(event=event):
self.set_up_event(event=event)
for participant in self.participants:
export_participant = self.export_objects["participants"][
participant.pk
]
preferences = [
_preference_json(pref)
for pref in AKPreference.objects.filter(
participant=participant, preference__gt=0
).select_related("slot")
]
self.assertCountEqual(
export_participant["preferences"], preferences
)
for idx, owner in enumerate(self.owners, self.max_participant_pk + 1):
owned_slots = self.ak_slots.filter(ak__owners=owner).all()
if not owned_slots:
continue
preferences = [
{
"ak_id": slot.pk,
"required": True,
"preference_score": -1,
}
for slot in owned_slots
]
export_participant = self.export_objects["participants"][idx]
self.assertCountEqual(
export_participant["preferences"], preferences
)
from django.test import TestCase
from AKModel.tests.test_views import BasicViewTests
class ModelViewTests(BasicViewTests, TestCase):
"""
Tests for AKSolverInterface
"""
fixtures = ["model.json"]
VIEWS_STAFF_ONLY = [
("admin:ak_json_export", {"event_slug": "kif42"}),
("admin:ak_schedule_json_import", {"event_slug": "kif42"}),
]
from django.urls import path
from .views import AKJSONExportView, AKScheduleJSONImportView
def get_admin_urls_solver_interface(admin_site):
return [
path(
"<slug:event_slug>/ak-json-export/",
admin_site.admin_view(AKJSONExportView.as_view()),
name="ak_json_export",
),
path(
"<slug:event_slug>/ak-schedule-json-import/",
admin_site.admin_view(AKScheduleJSONImportView.as_view()),
name="ak_schedule_json_import",
),
]
from pathlib import Path
import referencing.retrieval
from jsonschema import Draft202012Validator
from jsonschema.protocols import Validator
from referencing import Registry
from AKPlanning import settings
def _construct_schema_path(uri: str | Path) -> Path:
"""Construct a schema URI.
This function also checks for unallowed directory traversals
out of the 'schema' subfolder.
"""
schema_base_path = Path(settings.BASE_DIR).resolve()
uri_path = (schema_base_path / uri).resolve()
if not uri_path.is_relative_to(schema_base_path / "schemas"):
raise ValueError("Unallowed dictionary traversal")
return uri_path
@referencing.retrieval.to_cached_resource()
def retrieve_schema_from_disk(uri: str) -> str:
"""Retrieve schemas from disk by URI."""
uri_path = _construct_schema_path(uri)
with uri_path.open("r") as ff:
return ff.read()
def construct_schema_validator(schema: str | dict) -> Validator:
"""Construct a validator for a JSON schema.
In particular, all schemas from the 'schemas' directory
are loaded into the registry.
"""
registry = Registry(retrieve=retrieve_schema_from_disk)
if isinstance(schema, str):
schema_uri = str(Path("schemas") / schema)
schema = registry.get_or_retrieve(schema_uri).value.contents
return Draft202012Validator(schema=schema, registry=registry)
import json
from django.contrib import messages
from django.shortcuts import redirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import DetailView
from AKModel.metaviews.admin import (
AdminViewMixin,
EventSlugMixin,
IntermediateAdminView,
)
from AKModel.models import Event
from AKSolverInterface.forms import JSONScheduleImportForm
from AKSolverInterface.serializers import ExportEventSerializer
class AKJSONExportView(AdminViewMixin, DetailView):
"""
View: Export all AK slots of this event in JSON format ordered by tracks
"""
template_name = "admin/AKSolverInterface/ak_json_export.html"
model = Event
context_object_name = "event"
title = _("AK JSON Export")
slug_url_kwarg = "event_slug"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
try:
serialized_event = ExportEventSerializer(context["event"])
context["json_data_oneline"] = json.dumps(serialized_event.data, ensure_ascii=False)
context["json_data"] = json.dumps(serialized_event.data, indent=2, ensure_ascii=False)
context["is_valid"] = True
except ValueError as ex:
messages.add_message(
self.request,
messages.ERROR,
_("Exporting AKs for the solver failed! Reason: ") + str(ex),
)
return context
def get(self, request, *args, **kwargs):
# as this code is adapted from BaseDetailView::get
# pylint: disable=attribute-defined-outside-init
self.object = self.get_object()
context = self.get_context_data(object=self.object)
# if serialization failed in `get_context_data` we redirect to
# the status page and show a message instead
if not context.get("is_valid", False):
return redirect("admin:event_status", context["event"].slug)
return self.render_to_response(context)
class AKScheduleJSONImportView(EventSlugMixin, IntermediateAdminView):
"""
View: Import an AK schedule from a json file that can be pasted into this view.
"""
template_name = "admin/AKSolverInterface/import_json.html"
form_class = JSONScheduleImportForm
title = _("AK Schedule JSON Import")
def form_valid(self, form):
try:
number_of_slots_changed = self.event.schedule_from_json(
form.cleaned_data["data"]
)
messages.add_message(
self.request,
messages.SUCCESS,
_("Successfully imported {n} slot(s)").format(
n=number_of_slots_changed
),
)
except ValueError as ex:
messages.add_message(
self.request,
messages.ERROR,
_("Importing an AK schedule failed! Reason: ") + str(ex),
)
return redirect("admin:event_status", self.event.slug)
# Register your models here.
from datetime import datetime
from rest_framework import status
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.utils.datetime_safe import datetime
from AKModel.models import AK
......@@ -17,17 +18,21 @@ def ak_interest_indication_active(event, current_timestamp):
:return: True if indication is allowed, False if not
:rtype: Bool
"""
return event.active and (event.interest_start is None or (event.interest_start <= current_timestamp and (
event.interest_end is None or current_timestamp <= event.interest_end)))
return (event.active and event.interest_start is not None and event.interest_end is not None
and event.interest_start <= current_timestamp <= event.interest_end)
@api_view(['POST'])
def increment_interest_counter(request, event_slug, pk, **kwargs):
"""
Increment interest counter for AK
This view either returns an HTTP 200 if the counter was incremented,
an HTTP 403 if indicating interest is currently not allowed,
or an HTTP 404 if there is no matching AK for the given primary key and event slug.
"""
try:
ak = AK.objects.get(pk=pk)
ak = AK.objects.get(pk=pk, event__slug=event_slug)
# Check whether interest indication is currently allowed
current_timestamp = datetime.now().astimezone(ak.event.timezone)
if ak_interest_indication_active(ak.event, current_timestamp):
......
......@@ -2,4 +2,7 @@ from django.apps import AppConfig
class AksubmissionConfig(AppConfig):
"""
App configuration (default, only specifies name of the app)
"""
name = 'AKSubmission'
"""
Submission-specific forms
"""
import itertools
import re
......@@ -7,10 +11,21 @@ from django.utils.translation import gettext_lazy as _
from AKModel.availability.forms import AvailabilitiesFormMixin
from AKModel.availability.models import Availability
from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage, Event
from AKModel.models import AK, AKOwner, AKCategory, AKRequirement, AKSlot, AKOrgaMessage, AKType
class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
"""
Base form to add and edit AKs
Contains suitable widgets for the different data types, restricts querysets (e.g., of requirements) to entries
belonging to the event this AK belongs to.
Prepares initial slot creation (by accepting multiple input formats and a list of slots to generate),
automatically generate short names and wiki links if necessary
Will be modified/used by :class:`AKSubmissionForm` (that allows to add slots and excludes links)
and :class:`AKWishForm`
"""
required_css_class = 'required'
split_string = re.compile('[,;]')
......@@ -18,11 +33,11 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
model = AK
fields = ['name',
'short_name',
'link',
'protocol_link',
'owners',
'description',
'category',
'types',
'reso',
'present',
'requirements',
......@@ -34,6 +49,7 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
widgets = {
'requirements': forms.CheckboxSelectMultiple,
'types': forms.CheckboxSelectMultiple,
'event': forms.HiddenInput,
}
......@@ -47,7 +63,14 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
self.fields["prerequisites"].widget.attrs = {'class': 'chosen-select'}
self.fields['category'].queryset = AKCategory.objects.filter(event=self.initial.get('event'))
self.fields['types'].queryset = AKType.objects.filter(event=self.initial.get('event'))
# Don't ask for types if there are no types configured for this event
if self.fields['types'].queryset.count() == 0:
self.fields.pop('types')
self.fields['requirements'].queryset = AKRequirement.objects.filter(event=self.initial.get('event'))
# 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')
self.fields['prerequisites'].queryset = AK.objects.filter(event=self.initial.get('event')).exclude(
pk=self.instance.pk)
self.fields['conflicts'].queryset = AK.objects.filter(event=self.initial.get('event')).exclude(
......@@ -57,7 +80,14 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
@staticmethod
def _clean_duration(duration):
# Handle different duration formats (h:mm and decimal comma instead of point)
"""
Clean/convert input format for the duration(s) of the slot(s)
Handle different duration formats (h:mm and decimal comma instead of point)
:param duration: raw input, either with ":", "," or "."
:return: normalized duration (point-separated hour float)
"""
if ":" in duration:
h, m = duration.split(":")
duration = int(h) + int(m) / 60
......@@ -65,40 +95,47 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
duration = float(duration.replace(",", "."))
try:
float(duration)
except ValueError:
duration = float(duration)
except ValueError as exc:
raise ValidationError(
_('"%(duration)s" is not a valid duration'),
code='invalid',
params={'duration': duration},
)
) from exc
return duration
def clean(self):
"""
Normalize/clean inputs
Generate a (not yet used) short name if field was left blank,
create a list of normalized slot durations
:return: cleaned inputs
"""
cleaned_data = super().clean()
# Generate short name if not given
short_name = self.cleaned_data["short_name"]
if len(short_name) == 0:
short_name = self.cleaned_data['name']
# First try to split AK name at positions with semantic value (e.g., where the full name is separated
# by a ':'), if not possible, do a hard cut at the maximum specified length
short_name = short_name.partition(':')[0]
short_name = short_name.partition(' - ')[0]
short_name = short_name.partition(' (')[0]
short_name = short_name[:AK._meta.get_field('short_name').max_length]
# Check whether this short name already exists...
for i in itertools.count(1):
# ...and either use it...
if not AK.objects.filter(short_name=short_name, event=self.cleaned_data["event"]).exists():
break
# ... or postfix a number starting at 1 and growing until an unused short name is found
digits = len(str(i))
short_name = '{}-{}'.format(short_name[:-(digits + 1)], i)
short_name = f'{short_name[:-(digits + 1)]}-{i}'
cleaned_data["short_name"] = short_name
# Generate wiki link
if self.cleaned_data["event"].base_url:
link = self.cleaned_data["event"].base_url + self.cleaned_data["name"].replace(" ", "_")
# Truncate links longer than 200 characters (default length of URL fields in django)
self.cleaned_data["link"] = link[:200]
# Get durations from raw durations field
if "durations" in cleaned_data:
cleaned_data["durations"] = [self._clean_duration(d) for d in self.cleaned_data["durations"].split()]
......@@ -106,35 +143,63 @@ class AKForm(AvailabilitiesFormMixin, forms.ModelForm):
class AKSubmissionForm(AKForm):
"""
Form for Submitting new AKs
Is a special variant of :class:`AKForm` that does not allow to manually edit wiki and protocol links and enforces
the generation of at least one slot.
"""
class Meta(AKForm.Meta):
exclude = ['link', 'protocol_link']
# Exclude fields again that were previously included in the parent class
exclude = ['link', 'protocol_link'] #pylint: disable=modelform-uses-exclude
widgets = AKForm.Meta.widgets | {
'types': forms.CheckboxSelectMultiple(attrs={'checked' : 'checked'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Add field for durations
# Add field for durations (cleaning will be handled by parent class)
self.fields["durations"] = forms.CharField(
widget=forms.Textarea,
label=_("Duration(s)"),
help_text=_(
"Enter at least one planned duration (in hours). If your AK should have multiple slots, use multiple lines"),
initial=
self.initial.get('event').default_slot
"Enter at least one planned duration (in hours). "
"If your AK should have multiple slots, use multiple lines"),
initial=self.initial.get('event').default_slot
)
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 the user did not specify availabilities assume the full event duration is possible
if len(availabilities) == 0:
availabilities.append(Availability.with_event_length(event=self.cleaned_data["event"]))
return availabilities
class AKWishForm(AKForm):
"""
Form for submitting or editing wishes
Is a special variant of :class:`AKForm` that does not allow to specify owner(s) or
manually edit wiki and protocol links
"""
class Meta(AKForm.Meta):
exclude = ['owners', 'link', 'protocol_link']
# Exclude fields again that were previously included in the parent class
exclude = ['owners', 'link', 'protocol_link'] #pylint: disable=modelform-uses-exclude
widgets = AKForm.Meta.widgets | {
'types': forms.CheckboxSelectMultiple(attrs={'checked': 'checked'}),
}
class AKOwnerForm(forms.ModelForm):
"""
Form to create/edit AK owners
"""
required_css_class = 'required'
class Meta:
......@@ -146,6 +211,9 @@ class AKOwnerForm(forms.ModelForm):
class AKDurationForm(forms.ModelForm):
"""
Form to add an additional slot to a given AK
"""
class Meta:
model = AKSlot
fields = ['duration', 'ak', 'event']
......@@ -156,6 +224,9 @@ class AKDurationForm(forms.ModelForm):
class AKOrgaMessageForm(forms.ModelForm):
"""
Form to create a confidential message to the organizers belonging to a given AK
"""
class Meta:
model = AKOrgaMessage
fields = ['ak', 'text', 'event']
......@@ -164,14 +235,3 @@ class AKOrgaMessageForm(forms.ModelForm):
'event': forms.HiddenInput,
'text': forms.Textarea,
}
class AKAddSlotForm(forms.Form):
start = forms.CharField(label=_("Start"), disabled=True)
end = forms.CharField(label=_("End"), disabled=True)
duration = forms.CharField(label=_("Duration"), disabled=True)
room = forms.IntegerField(label=_("Room"), disabled=True)
def __init__(self, event):
super().__init__()
self.fields['ak'] = forms.ModelChoiceField(event.ak_set.all(), label=_("AK"))
......@@ -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-03-25 15:58+0100\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,16 +17,16 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: AKSubmission/forms.py:71
#: AKSubmission/forms.py:101
#, python-format
msgid "\"%(duration)s\" is not a valid duration"
msgstr "\"%(duration)s\" ist keine gültige Dauer"
#: AKSubmission/forms.py:117
#: AKSubmission/forms.py:164
msgid "Duration(s)"
msgstr "Dauer(n)"
#: AKSubmission/forms.py:119
#: AKSubmission/forms.py:166
msgid ""
"Enter at least one planned duration (in hours). If your AK should have "
"multiple slots, use multiple lines"
......@@ -34,33 +34,6 @@ msgstr ""
"Mindestens eine geplante Dauer (in Stunden) angeben. Wenn der AK mehrere "
"Slots haben soll, mehrere Zeilen verwenden"
#: AKSubmission/forms.py:170
#: AKSubmission/templates/AKSubmission/ak_detail.html:309
msgid "Start"
msgstr "Start"
#: AKSubmission/forms.py:171
#: AKSubmission/templates/AKSubmission/ak_detail.html:310
msgid "End"
msgstr "Ende"
#: AKSubmission/forms.py:172
#: AKSubmission/templates/AKSubmission/ak_detail.html:239
#: AKSubmission/templates/AKSubmission/akslot_delete.html:35
msgid "Duration"
msgstr "Dauer"
#: AKSubmission/forms.py:173
#: AKSubmission/templates/AKSubmission/ak_detail.html:241
msgid "Room"
msgstr "Raum"
#: AKSubmission/forms.py:177
#: AKSubmission/templates/AKSubmission/ak_history.html:11
#: AKSubmission/templates/AKSubmission/akslot_delete.html:31
msgid "AK"
msgstr "AK"
#: AKSubmission/templates/AKSubmission/ak_detail.html:22
#: AKSubmission/templates/AKSubmission/ak_edit.html:13
#: AKSubmission/templates/AKSubmission/ak_history.html:16
......@@ -74,163 +47,176 @@ msgstr "AK"
#: AKSubmission/templates/AKSubmission/submission_overview.html:7
#: AKSubmission/templates/AKSubmission/submission_overview.html:11
#: AKSubmission/templates/AKSubmission/submission_overview.html:36
#: AKSubmission/templates/AKSubmission/submit_new.html:31
#: AKSubmission/templates/AKSubmission/submit_new.html:38
#: AKSubmission/templates/AKSubmission/submit_new_wish.html:13
msgid "AK Submission"
msgstr "AK-Eintragung"
#: AKSubmission/templates/AKSubmission/ak_detail.html:77
#: AKSubmission/templates/AKSubmission/ak_detail.html:126
#: AKSubmission/templates/AKSubmission/ak_interest_script.html:50
msgid "Interest indication currently not allowed. Sorry."
msgstr "Interessenangabe aktuell nicht erlaubt. Sorry."
#: AKSubmission/templates/AKSubmission/ak_detail.html:79
#: AKSubmission/templates/AKSubmission/ak_detail.html:128
#: AKSubmission/templates/AKSubmission/ak_interest_script.html:52
msgid "Could not save your interest. Sorry."
msgstr "Interesse konnte nicht gespeichert werden. Sorry."
#: AKSubmission/templates/AKSubmission/ak_detail.html:100
#: AKSubmission/templates/AKSubmission/ak_detail.html:149
msgid "Interest"
msgstr "Interesse"
#: AKSubmission/templates/AKSubmission/ak_detail.html:102
#: AKSubmission/templates/AKSubmission/ak_table.html:55
#: AKSubmission/templates/AKSubmission/ak_detail.html:151
#: AKSubmission/templates/AKSubmission/ak_table.html:65
msgid "Show Interest"
msgstr "Interesse bekunden"
#: AKSubmission/templates/AKSubmission/ak_detail.html:108
#: AKSubmission/templates/AKSubmission/ak_table.html:46
#: AKSubmission/templates/AKSubmission/ak_detail.html:157
#: AKSubmission/templates/AKSubmission/ak_table.html:56
msgid "Open external link"
msgstr "Externen Link öffnen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:113
#: AKSubmission/templates/AKSubmission/ak_detail.html:162
msgid "Open protocol link"
msgstr "Protokolllink öffnen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:118
#: AKSubmission/templates/AKSubmission/ak_detail.html:167
#: AKSubmission/templates/AKSubmission/ak_history.html:19
#: AKSubmission/templates/AKSubmission/ak_history.html:31
msgid "History"
msgstr "Versionsgeschichte"
#: AKSubmission/templates/AKSubmission/ak_detail.html:121
#: AKSubmission/templates/AKSubmission/ak_detail.html:170
#: AKSubmission/templates/AKSubmission/akmessage_add.html:8
#: AKSubmission/templates/AKSubmission/akmessage_add.html:16
#: AKSubmission/templates/AKSubmission/akmessage_add.html:22
msgid "Add confidential message to organizers"
msgstr "Sende eine private Nachricht an das Organisationsteam"
#: AKSubmission/templates/AKSubmission/ak_detail.html:124
#: AKSubmission/templates/AKSubmission/ak_detail.html:269
#: AKSubmission/templates/AKSubmission/ak_detail.html:173
#: AKSubmission/templates/AKSubmission/ak_detail.html:326
#: AKSubmission/templates/AKSubmission/ak_edit.html:16
#: AKSubmission/templates/AKSubmission/ak_table.html:51
#: AKSubmission/templates/AKSubmission/ak_table.html:61
msgid "Edit"
msgstr "Bearbeiten"
#: AKSubmission/templates/AKSubmission/ak_detail.html:129
#: AKSubmission/templates/AKSubmission/ak_detail.html:178
#: AKSubmission/templates/AKSubmission/ak_history.html:31
#: AKSubmission/templates/AKSubmission/ak_table.html:34
#: AKSubmission/templates/AKSubmission/ak_table.html:37
msgid "AK Wish"
msgstr "AK-Wunsch"
#: AKSubmission/templates/AKSubmission/ak_detail.html:136
#: AKSubmission/templates/AKSubmission/ak_detail.html:186
#, python-format
msgid ""
"\n"
" This AK currently takes place for another "
"%(featured_slot_remaining)s minute(s) in %(room)s.\n"
" &nbsp;\n"
" "
"This AK currently takes place for another <span v-html=\"timeUntilEnd\">"
"%(featured_slot_remaining)s</span> minute(s) in %(room)s.&nbsp;"
msgstr ""
"\n"
" Dieser AK findet noch %(featured_slot_remaining)s "
"Minute(n) in %(room)s statt.&nbsp;\n"
"Dieser AK findet noch <span v-html=\"timeUntilEnd\">"
"%(featured_slot_remaining)s</span> Minute(n) in %(room)s statt.&nbsp;\n"
" "
#: AKSubmission/templates/AKSubmission/ak_detail.html:142
#: AKSubmission/templates/AKSubmission/ak_detail.html:189
#, python-format
msgid ""
"\n"
" This AK starts in %(featured_slot_remaining)s "
"minute(s) in %(room)s.&nbsp;\n"
" "
"This AK starts in <span v-html=\"timeUntilStart\">"
"%(featured_slot_remaining)s</span> minute(s) in %(room)s.&nbsp;"
msgstr ""
"\n"
" This AK beginnt in %(featured_slot_remaining)s "
"Minute(n) in %(room)s.&nbsp;\n"
"Dieser AK beginnt in <span v-html=\"timeUntilStart\">"
"%(featured_slot_remaining)s</span> Minute(n) in %(room)s.&nbsp;\n"
" "
#: AKSubmission/templates/AKSubmission/ak_detail.html:149
#: AKSubmission/templates/AKSubmission/ak_detail.html:277
#: AKSubmission/templates/AKSubmission/ak_detail.html:194
#: AKSubmission/templates/AKSubmission/ak_detail.html:334
msgid "Go to virtual room"
msgstr "Zum virtuellen Raum"
#: AKSubmission/templates/AKSubmission/ak_detail.html:158
#: AKSubmission/templates/AKSubmission/ak_detail.html:205
#: AKSubmission/templates/AKSubmission/ak_table.html:10
msgid "Who?"
msgstr "Wer?"
#: AKSubmission/templates/AKSubmission/ak_detail.html:164
#: AKSubmission/templates/AKSubmission/ak_detail.html:211
#: AKSubmission/templates/AKSubmission/ak_history.html:36
#: AKSubmission/templates/AKSubmission/ak_table.html:11
msgid "Category"
msgstr "Kategorie"
#: AKSubmission/templates/AKSubmission/ak_detail.html:171
#: AKSubmission/templates/AKSubmission/ak_detail.html:218
#: AKSubmission/templates/AKSubmission/ak_table.html:13
msgid "Types"
msgstr "Typen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:228
#: AKSubmission/templates/AKSubmission/ak_history.html:37
msgid "Track"
msgstr "Track"
#: AKSubmission/templates/AKSubmission/ak_detail.html:177
#, fuzzy
#| msgid "Present results of this AK"
#: AKSubmission/templates/AKSubmission/ak_detail.html:234
msgid "Present this AK"
msgstr "Die Ergebnisse dieses AKs vorstellen"
msgstr "Diesen AK vorstellen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:182
#: AKSubmission/templates/AKSubmission/ak_detail.html:239
msgid "(Category Default)"
msgstr "(Kategorievoreinstellung)"
#: AKSubmission/templates/AKSubmission/ak_detail.html:188
#: AKSubmission/templates/AKSubmission/ak_detail.html:245
msgid "Reso intention?"
msgstr "Resoabsicht?"
#: AKSubmission/templates/AKSubmission/ak_detail.html:195
#: AKSubmission/templates/AKSubmission/ak_detail.html:252
msgid "Requirements"
msgstr "Anforderungen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:208
#: AKSubmission/templates/AKSubmission/ak_detail.html:265
msgid "Conflicting AKs"
msgstr "AK-Konflikte"
#: AKSubmission/templates/AKSubmission/ak_detail.html:216
#: AKSubmission/templates/AKSubmission/ak_detail.html:273
msgid "Prerequisite AKs"
msgstr "Vorausgesetzte AKs"
#: AKSubmission/templates/AKSubmission/ak_detail.html:224
#: AKSubmission/templates/AKSubmission/ak_detail.html:281
msgid "Notes"
msgstr "Notizen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:237
#: AKSubmission/templates/AKSubmission/ak_detail.html:294
msgid "When?"
msgstr "Wann?"
#: AKSubmission/templates/AKSubmission/ak_detail.html:272
#: AKSubmission/templates/AKSubmission/ak_detail.html:296
#: AKSubmission/templates/AKSubmission/akslot_delete.html:35
msgid "Duration"
msgstr "Dauer"
#: AKSubmission/templates/AKSubmission/ak_detail.html:298
msgid "Room"
msgstr "Raum"
#: AKSubmission/templates/AKSubmission/ak_detail.html:329
msgid "Delete"
msgstr "Löschen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:283
#: AKSubmission/templates/AKSubmission/ak_detail.html:340
msgid "Schedule"
msgstr "Schedule"
#: AKSubmission/templates/AKSubmission/ak_detail.html:295
#: AKSubmission/templates/AKSubmission/ak_detail.html:352
msgid "Add another slot"
msgstr "Einen neuen AK-Slot hinzufügen"
#: AKSubmission/templates/AKSubmission/ak_detail.html:305
#: AKSubmission/templates/AKSubmission/ak_detail.html:362
msgid "Possible Times"
msgstr "Mögliche Zeiten"
#: AKSubmission/templates/AKSubmission/ak_detail.html:366
msgid "Start"
msgstr "Start"
#: AKSubmission/templates/AKSubmission/ak_detail.html:367
msgid "End"
msgstr "Ende"
#: AKSubmission/templates/AKSubmission/ak_edit.html:8
#: AKSubmission/templates/AKSubmission/ak_history.html:11
#: AKSubmission/templates/AKSubmission/ak_overview.html:8
......@@ -260,6 +246,11 @@ msgstr ""
"Person hinzufügen, die noch nicht in der Liste ist. Ungespeicherte "
"Änderungen in diesem Formular gehen verloren."
#: AKSubmission/templates/AKSubmission/ak_history.html:11
#: AKSubmission/templates/AKSubmission/akslot_delete.html:31
msgid "AK"
msgstr "AK"
#: AKSubmission/templates/AKSubmission/ak_history.html:27
msgid "Back"
msgstr "Zurück"
......@@ -274,16 +265,16 @@ msgid "Time"
msgstr "Zeit"
#: AKSubmission/templates/AKSubmission/ak_history.html:48
#: AKSubmission/templates/AKSubmission/ak_table.html:25
#: AKSubmission/templates/AKSubmission/ak_table.html:28
msgid "Present results of this AK"
msgstr "Die Ergebnisse dieses AKs vorstellen"
#: AKSubmission/templates/AKSubmission/ak_history.html:52
#: AKSubmission/templates/AKSubmission/ak_table.html:29
#: AKSubmission/templates/AKSubmission/ak_table.html:32
msgid "Intends to submit a resolution"
msgstr "Beabsichtigt eine Resolution einzureichen"
#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:42
#: AKSubmission/templates/AKSubmission/ak_list.html:6 AKSubmission/views.py:82
msgid "All AKs"
msgstr "Alle AKs"
......@@ -299,11 +290,11 @@ msgstr "AK-Liste"
msgid "Add AK"
msgstr "AK hinzufügen"
#: AKSubmission/templates/AKSubmission/ak_table.html:42
#: AKSubmission/templates/AKSubmission/ak_table.html:52
msgid "Details"
msgstr "Details"
#: AKSubmission/templates/AKSubmission/ak_table.html:66
#: AKSubmission/templates/AKSubmission/ak_table.html:76
msgid "There are no AKs in this category yet"
msgstr "Es gibt noch keine AKs in dieser Kategorie"
......@@ -314,7 +305,7 @@ msgstr "Senden"
#: AKSubmission/templates/AKSubmission/akmessage_add.html:31
#: AKSubmission/templates/AKSubmission/akowner_create_update.html:26
#: AKSubmission/templates/AKSubmission/akslot_add_update.html:29
#: AKSubmission/templates/AKSubmission/submit_new.html:52
#: AKSubmission/templates/AKSubmission/submit_new.html:59
msgid "Reset Form"
msgstr "Formular leeren"
......@@ -322,7 +313,7 @@ msgstr "Formular leeren"
#: AKSubmission/templates/AKSubmission/akowner_create_update.html:30
#: AKSubmission/templates/AKSubmission/akslot_add_update.html:33
#: AKSubmission/templates/AKSubmission/akslot_delete.html:45
#: AKSubmission/templates/AKSubmission/submit_new.html:56
#: AKSubmission/templates/AKSubmission/submit_new.html:63
msgid "Cancel"
msgstr "Abbrechen"
......@@ -390,8 +381,8 @@ msgstr "Ich leite bisher keine AKs"
#: AKSubmission/templates/AKSubmission/submission_overview.html:67
#: AKSubmission/templates/AKSubmission/submit_new.html:9
#: AKSubmission/templates/AKSubmission/submit_new.html:34
#: AKSubmission/templates/AKSubmission/submit_new.html:41
#: AKSubmission/templates/AKSubmission/submit_new.html:48
msgid "New AK"
msgstr "Neuer AK"
......@@ -405,78 +396,88 @@ msgstr ""
"Dieses Event is nicht aktiv. Es können keine AKs hinzugefügt oder bearbeitet "
"werden"
#: AKSubmission/templates/AKSubmission/submit_new.html:48
#: AKSubmission/templates/AKSubmission/submit_new.html:29
msgid ""
"only relevant for KIF-AKs - determines whether the AK appears in the slides "
"for the closing plenary session"
msgstr "nur relevant für KIF-AKs - entscheidet, ob der AK in den Folien fürs Abschlussplenum auftaucht"
#: AKSubmission/templates/AKSubmission/submit_new.html:55
msgid "Submit"
msgstr "Eintragen"
#: AKSubmission/views.py:73
#: AKSubmission/views.py:125
msgid "Wishes"
msgstr "Wünsche"
#: AKSubmission/views.py:73
#: AKSubmission/views.py:125
msgid "AKs one would like to have"
msgstr ""
"AKs die sich gewünscht wurden, aber bei denen noch nicht klar ist, wer sie "
"macht. Falls du dir das vorstellen kannst, trag dich einfach ein"
#: AKSubmission/views.py:93
#: AKSubmission/views.py:167
msgid "Currently planned AKs"
msgstr "Aktuell geplante AKs"
#: AKSubmission/views.py:186
#: AKSubmission/views.py:231
msgid "AKs with Track"
msgstr "AKs mit Track"
#: AKSubmission/views.py:300
msgid "Event inactive. Cannot create or update."
msgstr "Event inaktiv. Hinzufügen/Bearbeiten nicht möglich."
#: AKSubmission/views.py:202
#: AKSubmission/views.py:330
msgid "AK successfully created"
msgstr "AK erfolgreich angelegt"
#: AKSubmission/views.py:252
#: AKSubmission/views.py:404
msgid "AK successfully updated"
msgstr "AK erfolgreich aktualisiert"
#: AKSubmission/views.py:290
#: AKSubmission/views.py:455
#, python-brace-format
msgid "Added '{owner}' as new owner of '{ak.name}'"
msgstr "'{owner}' als neue Leitung von '{ak.name}' hinzugefügt"
#: AKSubmission/views.py:333
msgid "Person Info successfully updated"
msgstr "Personen-Info erfolgreich aktualisiert"
#: AKSubmission/views.py:355
#: AKSubmission/views.py:558
msgid "No user selected"
msgstr "Keine Person ausgewählt"
#: AKSubmission/views.py:382
#: AKSubmission/views.py:574
msgid "Person Info successfully updated"
msgstr "Personen-Info erfolgreich aktualisiert"
#: AKSubmission/views.py:610
msgid "AK Slot successfully added"
msgstr "AK-Slot erfolgreich angelegt"
#: AKSubmission/views.py:395
#: AKSubmission/views.py:629
msgid "You cannot edit a slot that has already been scheduled"
msgstr "Bereits geplante AK-Slots können nicht mehr bearbeitet werden"
#: AKSubmission/views.py:405
#: AKSubmission/views.py:639
msgid "AK Slot successfully updated"
msgstr "AK-Slot erfolgreich aktualisiert"
#: AKSubmission/views.py:417
#: AKSubmission/views.py:657
msgid "You cannot delete a slot that has already been scheduled"
msgstr "Bereits geplante AK-Slots können nicht mehr gelöscht werden"
#: AKSubmission/views.py:427
#: AKSubmission/views.py:667
msgid "AK Slot successfully deleted"
msgstr "AK-Slot erfolgreich angelegt"
#: AKSubmission/views.py:434
#: AKSubmission/views.py:679
msgid "Messages"
msgstr "Nachrichten"
#: AKSubmission/views.py:444
#: AKSubmission/views.py:689
msgid "Delete all messages"
msgstr "Alle Nachrichten löschen"
#: AKSubmission/views.py:467
#: AKSubmission/views.py:716
msgid "Message to organizers successfully saved"
msgstr "Nachricht an die Organisator*innen erfolgreich gespeichert"
......
......@@ -3,14 +3,15 @@ from django.conf import settings
from django.core.mail import EmailMessage
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse_lazy
from AKModel.models import AKOrgaMessage, AKSlot
@receiver(post_save, sender=AKOrgaMessage)
def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs):
# React to newly created Orga message by sending an email
def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwargs): # pylint: disable=unused-argument
"""
React to newly created Orga message by sending an email
"""
if created and settings.SEND_MAILS:
host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000'
......@@ -26,10 +27,12 @@ def orga_message_saved_handler(sender, instance: AKOrgaMessage, created, **kwarg
@receiver(post_save, sender=AKSlot)
def slot_created_handler(sender, instance: AKSlot, created, **kwargs):
# React to slots that are created after the plan was already published by sending an email
if created and settings.SEND_MAILS and apps.is_installed("AKPlan") and not instance.event.plan_hidden and instance.room is None and instance.start is None:
def slot_created_handler(sender, instance: AKSlot, created, **kwargs): # pylint: disable=unused-argument
"""
React to slots that are created after the plan was already published by sending an email
"""
if created and settings.SEND_MAILS and apps.is_installed("AKPlan") \
and not instance.event.plan_hidden and instance.room is None and instance.start is None: # pylint: disable=too-many-boolean-expressions,line-too-long
host = 'https://' + settings.ALLOWED_HOSTS[0] if len(settings.ALLOWED_HOSTS) > 0 else 'http://127.0.0.1:8000'
url = f"{host}{instance.ak.detail_url}"
......
......@@ -27,6 +27,55 @@
{% block imports %}
{% include "AKPlan/plan_akslot.html" %}
<script type="module">
const { createApp } = Vue
function getCurrentTimestamp() {
return Date.now() / 1000
}
createApp({
delimiters: ["[[", "]]"],
data() {
return {
featuredSlot: "{% if featured_slot %}true{% else %}false{% endif %}",
timer: null,
now: getCurrentTimestamp(),
akStart: "{{ featured_slot.start | date:'U' }}",
akEnd: "{{ featured_slot.end | date:'U' }}",
showBoxWithoutJS: false,
}
},
computed: {
showFeatured() {
return this.featuredSlot && this.now < this.akEnd
},
isBefore() {
return this.featuredSlot && this.now < this.akStart
},
isDuring() {
return this.featuredSlot && this.akStart < this.now && this.now < this.akEnd
},
timeUntilStart() {
return Math.ceil((this.akStart - this.now) / 60)
},
timeUntilEnd() {
return Math.floor((this.akEnd - this.now) / 60)
}
},
mounted: function () {
if(this.featuredSlot) {
this.timer = setInterval(() => {
this.now = getCurrentTimestamp()
}, 10000)
}
},
beforeUnmount() {
clearInterval(this.timer)
}
}).mount('#app')
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// CSRF Protection/Authentication
......@@ -128,30 +177,28 @@
<h2>{% if ak.wish %}{% trans "AK Wish" %}: {% endif %}{{ ak.name }}</h2>
{# Show current or upcoming slot featured in a box on top of the plage #}
{% if featured_slot_type != "NONE" %}
<div class="card border-success mt-3 mb-3">
<div class="card-body font-weight-bold">
{% if featured_slot_type == "CURRENT" %}
{% blocktrans with room=featured_slot.room %}
This AK currently takes place for another {{ featured_slot_remaining }} minute(s) in {{ room }}.
&nbsp;
{% endblocktrans %}
{% elif featured_slot_type == "UPCOMING" %}
{% blocktrans with room=featured_slot.room %}
This AK starts in {{ featured_slot_remaining }} minute(s) in {{ room }}.&nbsp;
{% endblocktrans %}
{% endif %}
<div id="app">
{# Show current or upcoming slot featured in a box on top of the plage #}
{% if featured_slot_type != "NONE" %}
<div class="card border-success mt-3 mb-3" v-show="showFeatured">
<div class="card-body font-weight-bold">
<span v-show="isDuring" style="{% if not featured_slot_type == "CURRENT" %}display:none;{% endif %}">
{% blocktrans with room=featured_slot.room %}This AK currently takes place for another <span v-html="timeUntilEnd">{{ featured_slot_remaining }}</span> minute(s) in {{ room }}.&nbsp;{% endblocktrans %}
</span>
<span v-show="isBefore" style="{% if not featured_slot_type == "UPCOMING" %}display:none;{% endif %}">
{% blocktrans with room=featured_slot.room %}This AK starts in <span v-html="timeUntilStart">{{ featured_slot_remaining }}</span> minute(s) in {{ room }}.&nbsp;{% endblocktrans %}
</span>
{% if "AKOnline"|check_app_installed and featured_slot.room.virtual and featured_slot.room.virtual.url != '' %}
<a class="btn btn-success" target="_parent" href="{{ featured_slot.room.virtual.url }}">
{% fa6_icon 'external-link-alt' 'fas' %} {% trans "Go to virtual room" %}
</a>
{% endif %}
{% if "AKOnline"|check_app_installed and featured_slot.room.virtual and featured_slot.room.virtual.url != '' %}
<a class="btn btn-success" target="_parent" href="{{ featured_slot.room.virtual.url }}">
{% fa6_icon 'external-link-alt' 'fas' %} {% trans "Go to virtual room" %}
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
<table class="table table-borderless">
<tr>
......@@ -166,6 +213,16 @@
{% category_linked_badge ak.category ak.event.slug %}
</td>
</tr>
{% if ak.types.count > 0 %}
<tr>
<td>{% trans "Types" %}</td>
<td>
{% for type in ak.types.all %}
<span class="badge bg-info">{{ type }}</span>
{% endfor %}
</td>
</tr>
{% endif %}
{% if ak.track %}
<tr>
<td>{% trans 'Track' %}</td>
......
......@@ -9,6 +9,9 @@
<th>{% trans "Name" %}</th>
<th>{% trans "Who?" %}</th>
<th>{% trans 'Category' %}</th>
{% if show_types %}
<th>{% trans 'Types' %}</th>
{% endif %}
<th></th>
</tr>
</thead>
......@@ -37,6 +40,13 @@
{% endif %}
</td>
<td>{% category_linked_badge ak.category event.slug %}</td>
{% if show_types %}
<td>
{% for aktype in ak.types.all %}
<span class="badge bg-info">{{ aktype }}</span>
{% endfor %}
</td>
{% endif %}
<td class="text-end" style="white-space: nowrap;">
<a href="{{ ak.detail_url }}" data-bs-toggle="tooltip"
title="{% trans 'Details' %}"
......
......@@ -23,6 +23,13 @@
);
});
</script>
<style>
#id_present_helptext::after {
content: " ({% trans "only relevant for KIF-AKs - determines whether the AK appears in the slides for the closing plenary session" %})";
color: #6c757d;
}
</style>
{% endblock %}
{% block breadcrumbs %}
......
......@@ -6,6 +6,11 @@ register = template.Library()
@register.filter
def bool_symbol(bool_val):
"""
Show a nice icon instead of the string true/false
:param bool_val: boolean value to iconify
:return: check or times icon depending on the value
"""
if bool_val:
return fa6_icon("check", "fas")
return fa6_icon("times", "fas")
......@@ -13,14 +18,34 @@ def bool_symbol(bool_val):
@register.inclusion_tag("AKSubmission/tracks_list.html")
def track_list(tracks, event_slug):
"""
Generate a clickable list of tracks (one badge per track) based upon the tracks_list template
:param tracks: tracks to consider
:param event_slug: slug of this event, required for link creation
:return: html fragment containing track links
"""
return {"tracks": tracks, "event_slug": event_slug}
@register.inclusion_tag("AKSubmission/category_list.html")
def category_list(categories, event_slug):
"""
Generate a clickable list of categories (one badge per category) based upon the category_list template
:param categories: categories to consider
:param event_slug: slug of this event, required for link creation
:return: html fragment containing category links
"""
return {"categories": categories, "event_slug": event_slug}
@register.inclusion_tag("AKSubmission/category_linked_badge.html")
def category_linked_badge(category, event_slug):
"""
Generate a clickable category badge based upon the category_linked_badge template
:param category: category to show/link
:param event_slug: slug of this event, required for link creation
:return: html fragment containing badge
"""
return {"category": category, "event_slug": event_slug}
from datetime import timedelta
from datetime import datetime, timedelta
from django.test import TestCase
from django.urls import reverse_lazy
from django.utils.datetime_safe import datetime
from AKModel.models import AK, AKSlot, Event
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
from AKSubmission.forms import AKSubmissionForm
class ModelViewTests(BasicViewTests, TestCase):
"""
Testcases for AKSubmission app.
This extends :class:`BasicViewTests` for standard view and edit testcases
that are specified in this class as VIEWS and EDIT_TESTCASES.
Additionally several additional testcases, in particular to test the API
and the dispatching for owner selection and editing are specified.
"""
fixtures = ['model.json']
VIEWS = [
......@@ -37,8 +46,8 @@ class ModelViewTests(BasicViewTests, TestCase):
'expected_message': "AK successfully updated"},
{'view': 'akslot_edit', 'target_view': 'ak_detail', 'kwargs': {'event_slug': 'kif42', 'pk': 5},
'target_kwargs': {'event_slug': 'kif42', 'pk': 1}, 'expected_message': "AK Slot successfully updated"},
{'view': 'akowner_edit', 'target_view': 'submission_overview', 'kwargs': {'event_slug': 'kif42', 'slug': 'a'},
'target_kwargs': {'event_slug': 'kif42'}, 'expected_message': "Person Info successfully updated"},
{'view': 'akowner_edit', 'target_view': 'submission_overview', 'kwargs': {'event_slug': 'kif42', 'slug': 'a'},
'target_kwargs': {'event_slug': 'kif42'}, 'expected_message': "Person Info successfully updated"},
]
def test_akslot_edit_delete_prevention(self):
......@@ -47,24 +56,27 @@ class ModelViewTests(BasicViewTests, TestCase):
"""
self.client.logout()
view_name_with_prefix, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1}))
_, url = self._name_and_url(('akslot_edit', {'event_slug': 'kif42', 'pk': 1}))
response = self.client.get(url)
self.assertEqual(response.status_code, 302,
msg=f"AK Slot editing ({url}) possible even though slot was already scheduled")
self._assert_message(response, "You cannot edit a slot that has already been scheduled")
view_name_with_prefix, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1}))
_, url = self._name_and_url(('akslot_delete', {'event_slug': 'kif42', 'pk': 1}))
response = self.client.get(url)
self.assertEqual(response.status_code, 302,
msg=f"AK Slot deletion ({url}) possible even though slot was already scheduled")
self._assert_message(response, "You cannot delete a slot that has already been scheduled")
def test_slot_creation_deletion(self):
"""
Test creation and deletion of slots in frontend
"""
ak_args = {'event_slug': 'kif42', 'pk': 1}
redirect_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs=ak_args)
# Create a valid slot -> Redirect to AK detail page, message to user
count_slots = AK.objects.get(pk=1).akslot_set.count()
create_url = reverse_lazy(f"{self.APP_NAME}:akslot_add", kwargs=ak_args)
response = self.client.post(create_url, {'ak': 1, 'event': 2, 'duration': 1.5})
self.assertRedirects(response, redirect_url, status_code=302, target_status_code=200,
......@@ -75,6 +87,8 @@ class ModelViewTests(BasicViewTests, TestCase):
# Get primary key of newly created Slot
slot_pk = AK.objects.get(pk=1).akslot_set.order_by('pk').last().pk
# Edit the recently created slot: Make sure view is accessible, post change
# -> redirect to detail page, duration updated
edit_url = reverse_lazy(f"{self.APP_NAME}:akslot_edit", kwargs={'event_slug': 'kif42', 'pk': slot_pk})
response = self.client.get(edit_url)
self.assertEqual(response.status_code, 200, msg=f"Cant open edit view for newly created slot ({edit_url})")
......@@ -84,6 +98,8 @@ class ModelViewTests(BasicViewTests, TestCase):
self.assertEqual(AKSlot.objects.get(pk=slot_pk).duration, 2,
msg="Slot was not correctly changed")
# Delete recently created slot: Make sure view is accessible, post deletion
# -> redirect to detail page, slot deleted, message to user
deletion_url = reverse_lazy(f"{self.APP_NAME}:akslot_delete", kwargs={'event_slug': 'kif42', 'pk': slot_pk})
response = self.client.get(deletion_url)
self.assertEqual(response.status_code, 200,
......@@ -95,55 +111,78 @@ class ModelViewTests(BasicViewTests, TestCase):
self.assertEqual(AK.objects.get(pk=1).akslot_set.count(), count_slots, msg="AK still has to many slots")
def test_ak_owner_editing(self):
# Test editing of new user
"""
Test dispatch of user editing requests
"""
edit_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit_dispatch", kwargs={'event_slug': 'kif42'})
base_url = reverse_lazy(f"{self.APP_NAME}:submission_overview", kwargs={'event_slug': 'kif42'})
# Empty form/no user selected -> start page
response = self.client.post(edit_url, {'owner_id': -1})
self.assertRedirects(response, base_url, status_code=302, target_status_code=200,
msg_prefix="Did not redirect to start page even though no user was selected")
self._assert_message(response, "No user selected")
# Correct selection -> user edit page for that user
edit_redirect_url = reverse_lazy(f"{self.APP_NAME}:akowner_edit", kwargs={'event_slug': 'kif42', 'slug': 'a'})
response = self.client.post(edit_url, {'owner_id': 1})
self.assertRedirects(response, edit_redirect_url, status_code=302, target_status_code=200,
msg_prefix=f"Dispatch redirect failed (should go to {edit_redirect_url})")
def test_ak_owner_selection(self):
"""
Test dispatch of owner selection requests
"""
select_url = reverse_lazy(f"{self.APP_NAME}:akowner_select", kwargs={'event_slug': 'kif42'})
create_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'})
# Empty user selection -> create a new user view
response = self.client.post(select_url, {'owner_id': -1})
self.assertRedirects(response, create_url, status_code=302, target_status_code=200,
msg_prefix="Did not redirect to user create view even though no user was specified")
# Valid user selected -> redirect to view that allows to add a new AK with this user as owner
add_redirect_url = reverse_lazy(f"{self.APP_NAME}:submit_ak", kwargs={'event_slug': 'kif42', 'owner_slug': 'a'})
response = self.client.post(select_url, {'owner_id': 1})
self.assertRedirects(response, add_redirect_url, status_code=302, target_status_code=200,
msg_prefix=f"Dispatch redirect to ak submission page failed (should go to {add_redirect_url})")
msg_prefix=f"Dispatch redirect to ak submission page failed "
f"(should go to {add_redirect_url})")
def test_orga_message_submission(self):
"""
Test submission and storing of direct confident messages to organizers
"""
form_url = reverse_lazy(f"{self.APP_NAME}:akmessage_add", kwargs={'event_slug': 'kif42', 'pk': 1})
detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1})
count_messages = AK.objects.get(pk=1).akorgamessage_set.count()
# Test that submission view is accessible
response = self.client.get(form_url)
self.assertEqual(response.status_code, 200, msg="Could not load message form view")
# Test submission itself and the following redirect -> AK detail page
response = self.client.post(form_url, {'ak': 1, 'event': 2, 'text': 'Test message text'})
self.assertRedirects(response, detail_url, status_code=302, target_status_code=200,
msg_prefix=f"Did not trigger redirect to ak detail page ({detail_url})")
# Make sure message was correctly saved in database and user is notified about that
self._assert_message(response, "Message to organizers successfully saved")
self.assertEqual(AK.objects.get(pk=1).akorgamessage_set.count(), count_messages + 1,
msg="Message was not correctly saved")
def test_interest_api(self):
"""
Test interest indicating API (access, functionality)
"""
interest_api_url = "/kif42/api/ak/1/indicate-interest/"
ak = AK.objects.get(pk=1)
event = Event.objects.get(slug='kif42')
ak_interest_counter = ak.interest_counter
# Check Access method (only POST)
response = self.client.get(interest_api_url)
self.assertEqual(response.status_code, 405, "Should not be accessible via GET")
......@@ -151,6 +190,7 @@ class ModelViewTests(BasicViewTests, TestCase):
event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=+10)
event.save()
# Test correct indication -> HTTP 200, counter increased
response = self.client.post(interest_api_url)
self.assertEqual(response.status_code, 200, f"API end point not working ({interest_api_url})")
self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1, "Counter was not increased")
......@@ -158,31 +198,79 @@ class ModelViewTests(BasicViewTests, TestCase):
event.interest_end = datetime.now().astimezone(event.timezone) + timedelta(minutes=-2)
event.save()
# Test indication outside of indication window -> HTTP 403, counter not increased
response = self.client.post(interest_api_url)
self.assertEqual(response.status_code, 403,
"API end point still reachable even though interest indication window ended ({interest_api_url})")
"API end point still reachable even though interest indication window ended "
"({interest_api_url})")
self.assertEqual(AK.objects.get(pk=1).interest_counter, ak_interest_counter + 1,
"Counter was increased even though interest indication window ended")
# Test call for non-existing AK -> HTTP 403
invalid_interest_api_url = "/kif42/api/ak/-1/indicate-interest/"
response = self.client.post(invalid_interest_api_url)
self.assertEqual(response.status_code, 404, f"Invalid URL reachable ({interest_api_url})")
def test_adding_of_unknown_user(self):
"""
Test adding of a previously not existing owner to an AK
"""
# Pre-Check: AK detail page existing?
detail_url = reverse_lazy(f"{self.APP_NAME}:ak_detail", kwargs={'event_slug': 'kif42', 'pk': 1})
response = self.client.get(detail_url)
self.assertEqual(response.status_code, 200, msg="Could not load ak detail view")
# Make sure AK detail page contains a link to add a new owner
edit_url = reverse_lazy(f"{self.APP_NAME}:ak_edit", kwargs={'event_slug': 'kif42', 'pk': 1})
response = self.client.get(edit_url)
self.assertEqual(response.status_code, 200, msg="Could not load ak detail view")
self.assertContains(response, "Add person not in the list yet",
msg_prefix="Link to add unknown user not contained")
# Check adding of a new owner by posting an according request
# -> Redirect to AK detail page, message to user, owners list updated
self.assertEqual(AK.objects.get(pk=1).owners.count(), 1)
add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) + f"?add_to_existing_ak=1"
response = self.client.post(add_new_user_to_ak_url, {'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk})
add_new_user_to_ak_url = reverse_lazy(f"{self.APP_NAME}:akowner_create", kwargs={'event_slug': 'kif42'}) \
+ "?add_to_existing_ak=1"
response = self.client.post(add_new_user_to_ak_url,
{'name': 'New test owner', 'event': Event.get_by_slug('kif42').pk})
self.assertRedirects(response, detail_url,
msg_prefix=f"No correct redirect: {add_new_user_to_ak_url} (POST) -> {detail_url}")
self._assert_message(response, "Added 'New test owner' as new owner of 'Test AK Inhalt'")
self.assertEqual(AK.objects.get(pk=1).owners.count(), 2)
def test_visibility_requirements_in_submission_form(self):
"""
Test visibility of requirements field in submission form
"""
event = Event.get_by_slug('kif42')
form = AKSubmissionForm(data={'name': 'Test AK', 'event': event}, instance=None, initial={"event": event})
self.assertIn('requirements', form.fields,
msg="Requirements field not present in form even though event has requirements")
event2 = Event.objects.create(name='Event without requirements',
slug='no_req',
start=datetime.now().astimezone(event.timezone),
end=datetime.now().astimezone(event.timezone),
active=True)
form2 = AKSubmissionForm(data={'name': 'Test AK', 'event': event2}, instance=None, initial={"event": event2})
self.assertNotIn('requirements', form2.fields,
msg="Requirements field should not be present for events without requirements")
def test_visibility_types_in_submission_form(self):
"""
Test visibility of types field in submission form
"""
event = Event.get_by_slug('kif42')
form = AKSubmissionForm(data={'name': 'Test AK', 'event': event}, instance=None, initial={"event": event})
self.assertIn('types', form.fields,
msg="Requirements field not present in form even though event has requirements")
event2 = Event.objects.create(name='Event without types',
slug='no_types',
start=datetime.now().astimezone(event.timezone),
end=datetime.now().astimezone(event.timezone),
active=True)
form2 = AKSubmissionForm(data={'name': 'Test AK', 'event': event2}, instance=None, initial={"event": event2})
self.assertNotIn('types', form2.fields,
msg="Requirements field should not be present for events without types")
from datetime import timedelta
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from math import floor
from django.apps import apps
......@@ -7,43 +8,88 @@ from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils.datetime_safe import datetime
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView
from django.views.generic import CreateView, DeleteView, DetailView, ListView, TemplateView, UpdateView
from AKModel.availability.models import Availability
from AKModel.metaviews import status_manager
from AKModel.metaviews.status import TemplateStatusWidget
from AKModel.models import AK, AKCategory, AKOwner, AKSlot, AKTrack, AKOrgaMessage
from AKModel.metaviews.admin import EventSlugMixin, FilterByEventSlugMixin
from AKModel.metaviews.status import TemplateStatusWidget
from AKModel.models import AK, AKCategory, AKOrgaMessage, AKOwner, AKSlot, AKTrack
from AKSubmission.api import ak_interest_indication_active
from AKSubmission.forms import AKWishForm, AKOwnerForm, AKSubmissionForm, AKDurationForm, AKOrgaMessageForm, \
AKForm
from AKSubmission.forms import AKDurationForm, AKForm, AKOrgaMessageForm, AKOwnerForm, AKSubmissionForm, AKWishForm
class SubmissionErrorNotConfiguredView(EventSlugMixin, TemplateView):
"""
View to show when submission is not correctly configured yet for this event
and hence the submission component cannot be used already.
"""
template_name = "AKSubmission/submission_not_configured.html"
class AKOverviewView(FilterByEventSlugMixin, ListView):
"""
View: Show a tabbed list of AKs belonging to this event split by categories
Wishes show up in between of the other AKs in the category they belong to.
In contrast to :class:`SubmissionOverviewView` that inherits from this view,
on this view there is no form to add new AKs or edit owners.
Since the inherited version of this view will have a slightly different behaviour,
this view contains multiple methods that can be overriden for this adaption.
"""
model = AKCategory
context_object_name = "categories"
template_name = "AKSubmission/ak_overview.html"
wishes_as_category = False
def filter_aks(self, context, category):
return category.ak_set.select_related('event').prefetch_related('owners').all()
def filter_aks(self, context, category): # pylint: disable=unused-argument
"""
Filter which AKs to display based on the given context and category
In the default case, all AKs of that category are returned (including wishes)
:param context: context of the view
:param category: category to filter the AK list for
:return: filtered list of AKs for the given category
:rtype: QuerySet[AK]
"""
# Use prefetching and relation selection/joining to reduce the amount of necessary queries
return category.ak_set.select_related('event').prefetch_related('owners').prefetch_related('types').all()
def get_active_category_name(self, context):
"""
Get the category name to display by default/before further user interaction
In the default case, simply the first category (the one with the lowest ID for this event) is used
:param context: context of the view
:return: name of the default category
:rtype: str
"""
return context["categories_with_aks"][0][0].name
def get_table_title(self, context):
def get_table_title(self, context): # pylint: disable=unused-argument
"""
Specify the title above the AK list/table in this view
:param context: context of the view
:return: title to use
:rtype: str
"""
return _("All AKs")
def get(self, request, *args, **kwargs):
"""
Handle GET request
Overriden to allow checking for correct configuration and
redirect to error page if necessary (see :class:`SubmissionErrorNotConfiguredView`)
"""
self._load_event()
self.object_list = self.get_queryset()
self.object_list = self.get_queryset() # pylint: disable=attribute-defined-outside-init
# No categories yet? Redirect to configuration error page
if self.object_list.count() == 0:
......@@ -55,10 +101,16 @@ class AKOverviewView(FilterByEventSlugMixin, ListView):
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
# ==========================================================
# Sort AKs into different lists (by their category)
# ==========================================================
ak_wishes = []
categories_with_aks = []
# Loop over categories, load AKs (while filtering them if necessary) and create a list of (category, aks)-tuples
# Depending on the setting of self.wishes_as_category, wishes are either included
# or added to a special "Wish"-Category that is created on-the-fly to provide consistent handling in the
# template (without storing it in the database)
for category in context["categories"]:
aks_for_category = []
for ak in self.filter_aks(context, category):
......@@ -70,13 +122,17 @@ class AKOverviewView(FilterByEventSlugMixin, ListView):
if self.wishes_as_category:
categories_with_aks.append(
(AKCategory(name=_("Wishes"), pk=0, description=_("AKs one would like to have")), ak_wishes))
(AKCategory(name=_("Wishes"), pk=0, description=_("AKs one would like to have")), ak_wishes))
context["categories_with_aks"] = categories_with_aks
context["active_category"] = self.get_active_category_name(context)
context['table_title'] = self.get_table_title(context)
context['show_types'] = self.event.aktype_set.count() > 0
# ==========================================================
# Display interest indication button?
# ==========================================================
current_timestamp = datetime.now().astimezone(self.event.timezone)
context['interest_indication_active'] = ak_interest_indication_active(self.event, current_timestamp)
......@@ -84,12 +140,30 @@ class AKOverviewView(FilterByEventSlugMixin, ListView):
class SubmissionOverviewView(AKOverviewView):
"""
View: List of AKs and possibility to add AKs or adapt owner information
Main/start view of the component.
This view inherits from :class:`AKOverviewView`, but treats wishes as separate category if requested in the settings
and handles the change actions mentioned above.
"""
model = AKCategory
context_object_name = "categories"
template_name = "AKSubmission/submission_overview.html"
# this mainly steers the different handling of wishes
# since the code for that is already included in the parent class
wishes_as_category = settings.WISHES_AS_CATEGORY
def get_table_title(self, context):
"""
Specify the title above the AK list/table in this view
:param context: context of the view
:return: title to use
:rtype: str
"""
return _("Currently planned AKs")
def get_context_data(self, *, object_list=None, **kwargs):
......@@ -102,32 +176,75 @@ class SubmissionOverviewView(AKOverviewView):
class AKListByCategoryView(AKOverviewView):
"""
View: List of only the AKs belonging to a certain category.
This view inherits from :class:`AKOverviewView`, but produces only one list instead of a tabbed one.
"""
def dispatch(self, request, *args, **kwargs):
# Override dispatching
# Needed to handle the checking whether the category exists
# noinspection PyAttributeOutsideInit
# pylint: disable=attribute-defined-outside-init
self.category = get_object_or_404(AKCategory, pk=kwargs['category_pk'])
return super().dispatch(request, *args, **kwargs)
def get_active_category_name(self, context):
"""
Get the category name to display by default/before further user interaction
In this case, this will be the name of the category specified via pk
:param context: context of the view
:return: name of the category
:rtype: str
"""
return self.category.name
class AKListByTrackView(AKOverviewView):
"""
View: List of only the AKs belonging to a certain track.
This view inherits from :class:`AKOverviewView` and there will be one list per category
-- but only AKs of a certain given track will be included in them.
"""
def dispatch(self, request, *args, **kwargs):
self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk'])
# Override dispatching
# Needed to handle the checking whether the track exists
self.track = get_object_or_404(AKTrack, pk=kwargs['track_pk']) # pylint: disable=attribute-defined-outside-init
return super().dispatch(request, *args, **kwargs)
def filter_aks(self, context, category):
return category.ak_set.filter(track=self.track)
"""
Filter which AKs to display based on the given context and category
In this case, the list is further restricted by the track
:param context: context of the view
:param category: category to filter the AK list for
:return: filtered list of AKs for the given category
:rtype: QuerySet[AK]
"""
return super().filter_aks(context, category).filter(track=self.track)
def get_table_title(self, context):
return f"{_('AKs with Track')} = {self.track.name}"
class AKDetailView(EventSlugMixin, DetailView):
"""
View: AK Details
"""
model = AK
context_object_name = "ak"
template_name = "AKSubmission/ak_detail.html"
def get_queryset(self):
# Get information about the AK and do some query optimization
return super().get_queryset().select_related('event').prefetch_related('owners')
def get_context_data(self, *, object_list=None, **kwargs):
......@@ -163,29 +280,35 @@ class AKDetailView(EventSlugMixin, DetailView):
class AKHistoryView(EventSlugMixin, DetailView):
"""
View: Show history of a given AK
"""
model = AK
context_object_name = "ak"
template_name = "AKSubmission/ak_history.html"
class AKListView(FilterByEventSlugMixin, ListView):
model = AK
context_object_name = "AKs"
template_name = "AKSubmission/ak_overview.html"
table_title = ""
def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs)
context['categories'] = AKCategory.objects.filter(event=self.event)
context['tracks'] = AKTrack.objects.filter(event=self.event)
return context
class EventInactiveRedirectMixin:
"""
Mixin that will cause a redirect when actions are performed on an inactive event.
Will add a message explaining why the action was not performed to the user
and then redirect to start page of the submission component
"""
def get_error_message(self):
"""
Error message to display after redirect (can be adjusted by this method)
:return: error message
:rtype: str
"""
return _("Event inactive. Cannot create or update.")
def get(self, request, *args, **kwargs):
"""
Override GET request handling
Will either perform the redirect including the message creation or continue with the planned dispatching
"""
s = super().get(request, *args, **kwargs)
if not self.event.active:
messages.add_message(self.request, messages.ERROR, self.get_error_message())
......@@ -194,6 +317,11 @@ class EventInactiveRedirectMixin:
class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
"""
View: Submission form for AKs and Wishes
Base view, will be used by :class:`AKSubmissionView` and :class:`AKWishSubmissionView`
"""
model = AK
template_name = 'AKSubmission/submit_new.html'
form_class = AKSubmissionForm
......@@ -221,7 +349,15 @@ class AKAndAKWishSubmissionView(EventSlugMixin, EventInactiveRedirectMixin, Crea
class AKSubmissionView(AKAndAKWishSubmissionView):
"""
View: AK submission form
Extends :class:`AKAndAKWishSubmissionView`
"""
def get_initial(self):
# Load initial values for the form
# Used to directly add the first owner and the event this AK will belong to
initials = super(AKAndAKWishSubmissionView, self).get_initial()
initials['owners'] = [AKOwner.get_by_slug(self.event, self.kwargs['owner_slug'])]
initials['event'] = self.event
......@@ -234,33 +370,54 @@ class AKSubmissionView(AKAndAKWishSubmissionView):
class AKWishSubmissionView(AKAndAKWishSubmissionView):
"""
View: Wish submission form
Extends :class:`AKAndAKWishSubmissionView`
"""
template_name = 'AKSubmission/submit_new_wish.html'
form_class = AKWishForm
def get_initial(self):
# Load initial values for the form
# Used to directly select the event this AK will belong to
initials = super(AKAndAKWishSubmissionView, self).get_initial()
initials['event'] = self.event
return initials
class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView):
"""
View: Update an AK
This allows to change most fields of an AK as specified in :class:`AKSubmission.forms.AKForm`,
including the availabilities.
It will also handle the change from AK to wish and vice versa (triggered by adding or removing owners)
and automatically create or delete (unscheduled) slots
"""
model = AK
template_name = 'AKSubmission/ak_edit.html'
form_class = AKForm
def get_success_url(self):
# Redirection after successfully saving to detail page of AK where also a success message is displayed
messages.add_message(self.request, messages.SUCCESS, _("AK successfully updated"))
return self.object.detail_url
def form_valid(self, form):
# Handle valid form submission
# Only save when event is active, otherwise redirect
if not form.cleaned_data["event"].active:
messages.add_message(self.request, messages.ERROR, self.get_error_message())
return redirect(reverse_lazy('submit:submission_overview',
kwargs={'event_slug': form.cleaned_data["event"].slug}))
# Remember owner count before saving to know whether the AK changed its state between AK and wish
previous_owner_count = self.object.owners.count()
super_form_valid = super().form_valid(form)
# Perform saving and redirect handling by calling default/parent implementation of form_valid
redirect_response = super().form_valid(form)
# Did this AK change from wish to AK or vice versa?
new_owner_count = self.object.owners.count()
......@@ -273,15 +430,23 @@ class AKEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView):
# Delete all unscheduled slots
self.object.akslot_set.filter(start__isnull=True).delete()
return super_form_valid
# Redirect to success url
return redirect_response
class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
"""
View: Create a new owner
"""
model = AKOwner
template_name = 'AKSubmission/akowner_create_update.html'
form_class = AKOwnerForm
def get_success_url(self):
# The redirect url depends on the source this view was called from:
# Called from an existing AK? Add the new owner as an owner of that AK, notify the user and redirect to detail
# page of that AK
if "add_to_existing_ak" in self.request.GET:
ak_pk = self.request.GET['add_to_existing_ak']
ak = get_object_or_404(AK, pk=ak_pk)
......@@ -289,15 +454,20 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
messages.add_message(self.request, messages.SUCCESS,
_("Added '{owner}' as new owner of '{ak.name}'").format(owner=self.object, ak=ak))
return ak.detail_url
# Called from the submission overview? Offer the user to create a new AK with the recently created owner
# prefilled as owner of that AK in the creation form
return reverse_lazy('submit:submit_ak',
kwargs={'event_slug': self.kwargs['event_slug'], 'owner_slug': self.object.slug})
def get_initial(self):
initials = super(AKOwnerCreateView, self).get_initial()
# Set the event in the (hidden) event field in the form based on the URL this view was called with
initials = super().get_initial()
initials['event'] = self.event
return initials
def form_valid(self, form):
# Prevent changes if event is not active
if not form.cleaned_data["event"].active:
messages.add_message(self.request, messages.ERROR, self.get_error_message())
return redirect(reverse_lazy('submit:submission_overview',
......@@ -305,29 +475,97 @@ class AKOwnerCreateView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
return super().form_valid(form)
class AKOwnerSelectDispatchView(EventSlugMixin, View):
class AKOwnerDispatchView(ABC, EventSlugMixin, View):
"""
This view only serves as redirect to prepopulate the owners field in submission create view
Base view: Dispatch to correct view based upon
Will be used by :class:`AKOwnerSelectDispatchView` and :class:`AKOwnerEditDispatchView` to handle button clicks for
"New AK" and "Edit Person Info" in submission overview based upon the selection in the owner dropdown field
"""
@abstractmethod
def get_new_owner_redirect(self, event_slug):
"""
Get redirect when user selected "I do not own AKs yet"
:param event_slug: slug of the event, needed for constructing redirect
:return: redirect to perform
:rtype: HttpResponseRedirect
"""
@abstractmethod
def get_valid_owner_redirect(self, event_slug, owner):
"""
Get redirect when user selected "I do not own AKs yet"
:param event_slug: slug of the event, needed for constructing redirect
:param owner: owner to perform the dispatching for
:return: redirect to perform
:rtype: HttpResponseRedirect
"""
def post(self, request, *args, **kwargs):
# This view is solely meant to handle POST requests
# Perform dispatching based on the submitted owner_id
# No owner_id? Redirect to submission overview view
if "owner_id" not in request.POST:
return redirect('submit:submission_overview', event_slug=kwargs['event_slug'])
owner_id = request.POST["owner_id"]
# Special owner_id "-1" (value of "I do not own AKs yet)? Redirect to owner creation view
if owner_id == "-1":
return HttpResponseRedirect(
reverse_lazy('submit:akowner_create', kwargs={'event_slug': kwargs['event_slug']}))
return self.get_new_owner_redirect(kwargs['event_slug'])
# Normal owner_id given? Check vor validity and redirect to AK submission page with that owner prefilled
# or display a 404 error page if no owner for the given id can be found. The latter should only happen when the
# user manipulated the value before sending or when the owner was deleted in backend and the user did not
# reload the dropdown between deletion and sending the dispatch request
owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"])
return HttpResponseRedirect(
reverse_lazy('submit:submit_ak', kwargs={'event_slug': kwargs['event_slug'], 'owner_slug': owner.slug}))
return self.get_valid_owner_redirect(kwargs['event_slug'], owner)
def get(self, request, *args, **kwargs):
# This view should never be called with GET, perform a redirect to overview in that case
return redirect('submit:submission_overview', event_slug=kwargs['event_slug'])
class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView):
class AKOwnerSelectDispatchView(AKOwnerDispatchView):
"""
View: Handle submission from the owner selection dropdown in submission overview for AK creation
("New AK" button)
This view will perform redirects depending on the selection in the owner dropdown field.
Based upon the abstract base view :class:`AKOwnerDispatchView`.
"""
def get_new_owner_redirect(self, event_slug):
return redirect('submit:akowner_create', event_slug=event_slug)
def get_valid_owner_redirect(self, event_slug, owner):
return redirect('submit:submit_ak', event_slug=event_slug, owner_slug=owner.slug)
class AKOwnerEditDispatchView(AKOwnerDispatchView):
"""
View: Handle submission from the owner selection dropdown in submission overview for owner editing
("Edit Person Info" button)
This view will perform redirects depending on the selection in the owner dropdown field.
Based upon the abstract base view :class:`AKOwnerDispatchView`.
"""
def get_new_owner_redirect(self, event_slug):
messages.add_message(self.request, messages.WARNING, _("No user selected"))
return redirect('submit:submission_overview', event_slug)
def get_valid_owner_redirect(self, event_slug, owner):
return redirect('submit:akowner_edit', event_slug=event_slug, slug=owner.slug)
class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, EventInactiveRedirectMixin, UpdateView):
"""
View: Edit an owner
"""
model = AKOwner
template_name = "AKSubmission/akowner_create_update.html"
form_class = AKOwnerForm
......@@ -337,6 +575,7 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView):
return reverse_lazy('submit:submission_overview', kwargs={'event_slug': self.kwargs['event_slug']})
def form_valid(self, form):
# Prevent updating if event is not active
if not form.cleaned_data["event"].active:
messages.add_message(self.request, messages.ERROR, self.get_error_message())
return redirect(reverse_lazy('submit:submission_overview',
......@@ -344,36 +583,19 @@ class AKOwnerEditView(FilterByEventSlugMixin, EventSlugMixin, UpdateView):
return super().form_valid(form)
class AKOwnerEditDispatchView(EventSlugMixin, View):
"""
This view only serves as redirect choose the correct edit view
class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
"""
View: Add an additional slot to an AK
The user has to select the duration of the slot in this view
def post(self, request, *args, **kwargs):
if "owner_id" not in request.POST:
return redirect('submit:submission_overview', event_slug=kwargs['event_slug'])
owner_id = request.POST["owner_id"]
if owner_id == "-1":
messages.add_message(self.request, messages.WARNING, _("No user selected"))
return HttpResponseRedirect(
reverse_lazy('submit:submission_overview', kwargs={'event_slug': kwargs['event_slug']}))
owner = get_object_or_404(AKOwner, pk=request.POST["owner_id"])
return HttpResponseRedirect(
reverse_lazy('submit:akowner_edit', kwargs={'event_slug': kwargs['event_slug'], 'slug': owner.slug}))
def get(self, request, *args, **kwargs):
return redirect('submit:submission_overview', event_slug=kwargs['event_slug'])
class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`)
"""
model = AKSlot
form_class = AKDurationForm
template_name = "AKSubmission/akslot_add_update.html"
def get_initial(self):
initials = super(AKSlotAddView, self).get_initial()
initials = super().get_initial()
initials['event'] = self.event
initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk'])
initials['duration'] = self.event.default_slot
......@@ -390,6 +612,12 @@ class AKSlotAddView(EventSlugMixin, EventInactiveRedirectMixin, CreateView):
class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView):
"""
View: Update the duration of an AK slot
The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`)
and only slots that are not scheduled yet may be changed
"""
model = AKSlot
form_class = AKDurationForm
template_name = "AKSubmission/akslot_add_update.html"
......@@ -413,6 +641,12 @@ class AKSlotEditView(EventSlugMixin, EventInactiveRedirectMixin, UpdateView):
class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView):
"""
View: Delete an AK slot
The view will only process the request when the event is active (as steered by :class:`EventInactiveRedirectMixin`)
and only slots that are not scheduled yet may be deleted
"""
model = AKSlot
template_name = "AKSubmission/akslot_delete.html"
......@@ -436,6 +670,11 @@ class AKSlotDeleteView(EventSlugMixin, EventInactiveRedirectMixin, DeleteView):
@status_manager.register(name="event_ak_messages")
class EventAKMessagesWidget(TemplateStatusWidget):
"""
Status page widget: AK Messages
A widget to display information about AK-related messages sent to organizers for the given event
"""
required_context_type = "event"
title = _("Messages")
template_name = "admin/AKModel/render_ak_messages.html"
......@@ -454,12 +693,16 @@ class EventAKMessagesWidget(TemplateStatusWidget):
class AKAddOrgaMessageView(EventSlugMixin, CreateView):
"""
View: Form to create a (confidential) message to the organizers as defined in
:class:`AKSubmission.forms.AKOrgaMessageForm`
"""
model = AKOrgaMessage
form_class = AKOrgaMessageForm
template_name = "AKSubmission/akmessage_add.html"
def get_initial(self):
initials = super(AKAddOrgaMessageView, self).get_initial()
initials = super().get_initial()
initials['ak'] = get_object_or_404(AK, pk=self.kwargs['pk'])
initials['event'] = initials['ak'].event
return initials
......
......@@ -61,7 +61,7 @@ Provide more context by answering these questions:
Include details about your configuration and environment:
* **Which version (commit)) are you using?**
* **Which version (commit) are you using?**
* **What's the OS you're using**?
### Suggesting Enhancements
......
......@@ -17,3 +17,6 @@ Further contributions in the form of code, testing, documentation etc. were mad
* R. Zameitat [xayomer](https://gitlab.fachschaften.org/xayomer)
* N. Steinger [voidptr](https://gitlab.fachschaften.org/voidptr)
* T. Neumann [neumantm](https://gitlab.fachschaften.org/neumantm)
* F. Blanke [felix_bonn](https://gitlab.fachschaften.org/felix_bonn)
* L. Conti [lorax66](https://gitlab.fachschaften.org/lorax66)
* M. Marx [mmarx](https://gitlab.fachschaften.org/mmarx)