Skip to content
Snippets Groups Projects

Compare revisions

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

Source

Select target project
No results found
Select Git revision
  • komasolver
  • main
  • renovate/django_csp-4.x
  • renovate/jsonschema-4.x
4 results

Target

Select target project
  • konstantin/akplanning
  • matedealer/akplanning
  • kif/akplanning
  • mirco/akplanning
  • lordofthevoid/akplanning
  • voidptr/akplanning
  • xayomer/akplanning-fork
  • mollux/akplanning
  • neumantm/akplanning
  • mmarx/akplanning
  • nerf/akplanning
  • felix_bonn/akplanning
  • sebastian.uschmann/akplanning
13 results
Select Git revision
Show changes
Showing
with 1563 additions and 61 deletions
{% load tz %} {% load tz %}
{% load fontawesome_6 %}
{% timezone event.timezone %} {% timezone event.timezone %}
<table class="table table-striped"> <table class="table table-striped">
...@@ -7,7 +8,10 @@ ...@@ -7,7 +8,10 @@
<span class="text-secondary float-end"> <span class="text-secondary float-end">
{{ message.timestamp|date:"Y-m-d H:i:s" }} {{ message.timestamp|date:"Y-m-d H:i:s" }}
</span> </span>
<h5><a href="{{ message.ak.detail_url }}">{{ message.ak }}</a></h5> <h5><a href="{{ message.ak.detail_url }}">
{% if message.resolved %}{% fa6_icon "check-circle" %} {% endif %}
{{ message.ak }}
</a></h5>
<p>{{ message.text }}</p> <p>{{ message.text }}</p>
</td></tr> </td></tr>
{% endfor %} {% endfor %}
......
...@@ -3,35 +3,65 @@ from django.apps import apps ...@@ -3,35 +3,65 @@ from django.apps import apps
from django.conf import settings from django.conf import settings
from django.utils.html import format_html, mark_safe, conditional_escape from django.utils.html import format_html, mark_safe, conditional_escape
from django.templatetags.static import static from django.templatetags.static import static
from django.template.defaultfilters import date
from fontawesome_6.app_settings import get_css from fontawesome_6.app_settings import get_css
from AKModel.models import Event
register = template.Library() register = template.Library()
# Get Footer Info from settings
@register.simple_tag @register.simple_tag
def footer_info(): def footer_info():
"""
Get Footer Info from settings
:return: a dict of several strings like the impress URL to use in the footer
:rtype: Dict[str, str]
"""
return settings.FOOTER_INFO return settings.FOOTER_INFO
@register.filter @register.filter
def check_app_installed(name): def check_app_installed(name):
"""
Check whether the app with the given name is active in this instance
:param name: name of the app to check for
:return: true if app is installed
:rtype: bool
"""
return apps.is_installed(name) return apps.is_installed(name)
@register.filter @register.filter
def message_bootstrap_class(tag): def message_bootstrap_class(tag):
"""
Turn message severity classes into corresponding bootstrap css classes
:param tag: severity of the message
:return: matching bootstrap class
"""
if tag == "error": if tag == "error":
return "alert-danger" return "alert-danger"
elif tag == "success": if tag == "success":
return "alert-success" return "alert-success"
elif tag == "warning": if tag == "warning":
return "alert-warning" return "alert-warning"
return "alert-info" return "alert-info"
@register.filter @register.filter
def wiki_owners_export(owners, event): def wiki_owners_export(owners, event):
"""
Preserve owner link information for wiki export by using internal links if possible
but external links when owner specified a non-wikilink. This is applied to the full list of owners
:param owners: list of owners
:param event: event this owner belongs to and that is currently exported (specifying this directly prevents unnecessary database lookups) #pylint: disable=line-too-long
:return: linkified owners list in wiki syntax
:rtype: str
"""
def to_link(owner): def to_link(owner):
if owner.link != '': if owner.link != '':
event_link_prefix, _ = event.base_url.rsplit("/", 1) event_link_prefix, _ = event.base_url.rsplit("/", 1)
...@@ -44,17 +74,45 @@ def wiki_owners_export(owners, event): ...@@ -44,17 +74,45 @@ def wiki_owners_export(owners, event):
return ", ".join(to_link(owner) for owner in owners.all()) return ", ".join(to_link(owner) for owner in owners.all())
@register.filter
def event_month_year(event:Event):
"""
Print rough event date (month and year)
:param event: event to print the date for
:return: string containing rough date information for event
"""
if event.start.month == event.end.month:
return f"{date(event.start, 'F')} {event.start.year}"
event_start_string = date(event.start, 'F')
if event.start.year != event.end.year:
event_start_string = f"{event_start_string} {event.start.year}"
return f"{event_start_string} - {date(event.end, 'F')} {event.end.year}"
# get list of relevant css fontawesome css files for this instance
css = get_css() css = get_css()
@register.simple_tag @register.simple_tag
def fontawesome_6_css(): def fontawesome_6_css():
"""
Create html code to load all required fontawesome css files
:return: HTML code to load css
:rtype: str
"""
return mark_safe(conditional_escape('\n').join(format_html( return mark_safe(conditional_escape('\n').join(format_html(
'<link href="{}" rel="stylesheet" media="all">', stylesheet) for stylesheet in css)) '<link href="{}" rel="stylesheet" media="all">', stylesheet) for stylesheet in css))
@register.simple_tag @register.simple_tag
def fontawesome_6_js(): def fontawesome_6_js():
"""
Create html code to load all required fontawesome javascript files
:return: HTML code to load js
:rtype: str
"""
return mark_safe(format_html( return mark_safe(format_html(
'<script type="text/javascript" src="{}"></script>', static('fontawesome_6/js/django-fontawesome.js') '<script type="text/javascript" src="{}"></script>', static('fontawesome_6/js/django-fontawesome.js')
)) ))
\ No newline at end of file
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, AKSlot, DefaultSlot, Event, Room
from AKModel.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.slots_in_an_hour: float = 1.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.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)
self.assertEqual(
self.export_dict["participants"],
[],
"Empty participant list expected",
)
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 AKOwner.objects.filter(event=self.event).all()
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 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"]
)
import traceback import traceback
from typing import List from typing import List
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.contrib.messages.storage.base import Message from django.contrib.messages.storage.base import Message
from django.test import TestCase from django.test import TestCase
from django.urls import reverse_lazy, reverse from django.urls import reverse_lazy, reverse
from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \ from AKModel.models import (
ConstraintViolation, DefaultSlot Event,
AKOwner,
AKCategory,
AKTrack,
AKRequirement,
AK,
Room,
AKSlot,
AKOrgaMessage,
ConstraintViolation,
DefaultSlot,
)
class BasicViewTests: class BasicViewTests:
"""
Parent class for "standard" tests of views
Provided with a list of views and arguments (if necessary), this will test that views
- render correctly without errors
- are only reachable with the correct rights (neither too freely nor too restricted)
To do this, the test creates sample users, fixtures are loaded automatically by the django test framework.
It also provides helper functions, e.g., to check for correct messages to the user or more simply generate
the URLs to test
In this class, methods from :class:`TestCase` will be called at multiple places event though TestCase is not a
parent of this class but has to be included as parent in concrete implementations of this class seperately.
It however still makes sense to treat this class as some kind of mixin and not implement it as a child of TestCase,
since the test framework does not understand the concept of abstract test definitions and would handle this class
as real test case otherwise, distorting the test results.
"""
# pylint: disable=no-member
VIEWS = [] VIEWS = []
APP_NAME = '' APP_NAME = ""
VIEWS_STAFF_ONLY = [] VIEWS_STAFF_ONLY = []
EDIT_TESTCASES = [] EDIT_TESTCASES = []
def setUp(self): def setUp(self): # pylint: disable=invalid-name
self.staff_user = User.objects.create( """
username='Test Staff User', email='teststaff@example.com', password='staffpw', Setup testing by creating sample users
is_staff=True, is_active=True """
user_model = get_user_model()
self.staff_user = user_model.objects.create(
username="Test Staff User",
email="teststaff@example.com",
password="staffpw",
is_staff=True,
is_active=True,
) )
self.admin_user = User.objects.create( self.admin_user = user_model.objects.create(
username='Test Admin User', email='testadmin@example.com', password='adminpw', username="Test Admin User",
is_staff=True, is_superuser=True, is_active=True email="testadmin@example.com",
password="adminpw",
is_staff=True,
is_superuser=True,
is_active=True,
) )
self.deactivated_user = User.objects.create( self.deactivated_user = user_model.objects.create(
username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw', username="Test Deactivated User",
is_staff=True, is_active=False email="testdeactivated@example.com",
password="deactivatedpw",
is_staff=True,
is_active=False,
) )
def _name_and_url(self, view_name): def _name_and_url(self, view_name):
...@@ -40,12 +84,21 @@ class BasicViewTests: ...@@ -40,12 +84,21 @@ class BasicViewTests:
:return: full view name with prefix if applicable, url of the view :return: full view name with prefix if applicable, url of the view
:rtype: str, str :rtype: str, str
""" """
view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0] view_name_with_prefix = (
f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
)
url = reverse(view_name_with_prefix, kwargs=view_name[1]) url = reverse(view_name_with_prefix, kwargs=view_name[1])
return view_name_with_prefix, url return view_name_with_prefix, url
def _assert_message(self, response, expected_message, msg_prefix=""): def _assert_message(self, response, expected_message, msg_prefix=""):
messages:List[Message] = list(get_messages(response.wsgi_request)) """
Assert that the correct message is shown and cause test to fail if not
:param response: response to check
:param expected_message: message that should be shown
:param msg_prefix: prefix for the error message when test fails
"""
messages: List[Message] = list(get_messages(response.wsgi_request))
msg_count = "No message shown to user" msg_count = "No message shown to user"
msg_content = "Wrong message, expected '{expected_message}'" msg_content = "Wrong message, expected '{expected_message}'"
...@@ -59,60 +112,104 @@ class BasicViewTests: ...@@ -59,60 +112,104 @@ class BasicViewTests:
self.assertEqual(messages[-1].message, expected_message, msg=msg_content) self.assertEqual(messages[-1].message, expected_message, msg=msg_content)
def test_views_for_200(self): def test_views_for_200(self):
"""
Test the list of public views (as specified in "VIEWS") for error-free rendering
"""
for view_name in self.VIEWS: for view_name in self.VIEWS:
view_name_with_prefix, url = self._name_and_url(view_name) view_name_with_prefix, url = self._name_and_url(view_name)
try: try:
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken") self.assertEqual(
except Exception as e: response.status_code,
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}") 200,
msg=f"{view_name_with_prefix} ({url}) broken",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
def test_access_control_staff_only(self): def test_access_control_staff_only(self):
"""
Test whether internal views (as specified in "VIEWS_STAFF_ONLY" are visible to staff users and staff users only
"""
# Not logged in? Views should not be visible
self.client.logout() self.client.logout()
for view_name in self.VIEWS_STAFF_ONLY: for view_name_info in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name) expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 302, msg=f"{view_name_with_prefix} ({url}) accessible by non-staff") self.assertEqual(
response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) accessible by non-staff",
)
# Logged in? Views should be visible
self.client.force_login(self.staff_user) self.client.force_login(self.staff_user)
for view_name in self.VIEWS_STAFF_ONLY: for view_name_info in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name) view_name_with_prefix, url = self._name_and_url(view_name_info)
try: try:
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200, self.assertEqual(
msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)") response.status_code,
except Exception as e: 200,
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):\n\n{traceback.format_exc()}") msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
# Disabled user? Views should not be visible
self.client.force_login(self.deactivated_user) self.client.force_login(self.deactivated_user)
for view_name in self.VIEWS_STAFF_ONLY: for view_name_info in self.VIEWS_STAFF_ONLY:
view_name_with_prefix, url = self._name_and_url(view_name) expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 302, self.assertEqual(
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user") response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user",
)
def _to_sendable_value(self, v): def _to_sendable_value(self, val):
""" """
Create representation sendable via POST from form data Create representation sendable via POST from form data
:param v: value to prepare Needed to automatically check create, update and delete views
:type v: any
:param val: value to prepare
:type val: any
:return: prepared value (normally either raw value or primary key of complex object) :return: prepared value (normally either raw value or primary key of complex object)
""" """
if type(v) == list: if isinstance(val, list):
return [e.pk for e in v] return [e.pk for e in val]
if type(v) == "RelatedManager": if type(val) == "RelatedManager": # pylint: disable=unidiomatic-typecheck
return [e.pk for e in v.all()] return [e.pk for e in val.all()]
return v return val
def test_submit_edit_form(self): def test_submit_edit_form(self):
""" """
Test edit forms in the most simple way (sending them again unchanged) Test edit forms (as specified in "EDIT_TESTCASES") in the most simple way (sending them again unchanged)
""" """
for testcase in self.EDIT_TESTCASES: for testcase in self.EDIT_TESTCASES:
self._test_submit_edit_form(testcase) self._test_submit_edit_form(testcase)
def _test_submit_edit_form(self, testcase): def _test_submit_edit_form(self, testcase):
"""
Test a single edit form by rendering and sending it again unchanged
This will test for correct rendering, dispatching/redirecting, messages and access control handling
:param testcase: details of the form to test
"""
name, url = self._name_and_url((testcase["view"], testcase["kwargs"])) name, url = self._name_and_url((testcase["view"], testcase["kwargs"]))
form_name = testcase.get("form_name", "form") form_name = testcase.get("form_name", "form")
expected_code = testcase.get("expected_code", 302) expected_code = testcase.get("expected_code", 302)
...@@ -130,79 +227,137 @@ class BasicViewTests: ...@@ -130,79 +227,137 @@ class BasicViewTests:
self.client.logout() self.client.logout()
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{name}: Could not load edit form via GET ({url})") self.assertEqual(
response.status_code,
200,
msg=f"{name}: Could not load edit form via GET ({url})",
)
form = response.context[form_name] form = response.context[form_name]
data = {k:self._to_sendable_value(v) for k,v in form.initial.items()} data = {k: self._to_sendable_value(v) for k, v in form.initial.items()}
response = self.client.post(url, data=data) response = self.client.post(url, data=data)
if expected_code == 200: if expected_code == 200:
self.assertEqual(response.status_code, 200, msg=f"{name}: Did not return 200 ({url}") self.assertEqual(
response.status_code, 200, msg=f"{name}: Did not return 200 ({url}"
)
elif expected_code == 302: elif expected_code == 302:
self.assertRedirects(response, target_url, msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}") self.assertRedirects(
response,
target_url,
msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}",
)
if expected_message != "": if expected_message != "":
self._assert_message(response, expected_message, msg_prefix=f"{name}") self._assert_message(response, expected_message, msg_prefix=f"{name}")
class ModelViewTests(BasicViewTests, TestCase): class ModelViewTests(BasicViewTests, TestCase):
fixtures = ['model.json'] """
Basic view test cases for views from AKModel plus some custom tests
"""
fixtures = ["model.json"]
ADMIN_MODELS = [ ADMIN_MODELS = [
(Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'), (Event, "event"),
(AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'), (AKOwner, "akowner"),
(AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'), (AKCategory, "akcategory"),
(DefaultSlot, 'defaultslot') (AKTrack, "aktrack"),
(AKRequirement, "akrequirement"),
(AK, "ak"),
(Room, "room"),
(AKSlot, "akslot"),
(AKOrgaMessage, "akorgamessage"),
(ConstraintViolation, "constraintviolation"),
(DefaultSlot, "defaultslot"),
] ]
VIEWS_STAFF_ONLY = [ VIEWS_STAFF_ONLY = [
('admin:index', {}), ("admin:index", {}),
('admin:event_status', {'event_slug': 'kif42'}), ("admin:event_status", {"event_slug": "kif42"}),
('admin:event_requirement_overview', {'event_slug': 'kif42'}), ("admin:event_requirement_overview", {"event_slug": "kif42"}),
('admin:ak_csv_export', {'event_slug': 'kif42'}), ("admin:ak_csv_export", {"event_slug": "kif42"}),
('admin:ak_wiki_export', {'slug': 'kif42'}), ("admin:ak_json_export", {"event_slug": "kif42"}),
('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}), ("admin:ak_wiki_export", {"slug": "kif42"}),
('admin:ak_slide_export', {'event_slug': 'kif42'}), ("admin:ak_schedule_json_import", {"event_slug": "kif42"}),
('admin:default-slots-editor', {'event_slug': 'kif42'}), ("admin:ak_delete_orga_messages", {"event_slug": "kif42"}),
('admin:room-import', {'event_slug': 'kif42'}), ("admin:ak_slide_export", {"event_slug": "kif42"}),
('admin:new_event_wizard_start', {}), ("admin:default-slots-editor", {"event_slug": "kif42"}),
("admin:room-import", {"event_slug": "kif42"}),
("admin:new_event_wizard_start", {}),
] ]
EDIT_TESTCASES = [ EDIT_TESTCASES = [
{'view': 'admin:default-slots-editor', 'kwargs': {'event_slug': 'kif42'}, "admin": True}, {
"view": "admin:default-slots-editor",
"kwargs": {"event_slug": "kif42"},
"admin": True,
},
] ]
def test_admin(self): def test_admin(self):
"""
Test basic admin functionality (displaying and interacting with model instances)
"""
self.client.force_login(self.admin_user) self.client.force_login(self.admin_user)
for model in self.ADMIN_MODELS: for model in self.ADMIN_MODELS:
# Special treatment for a subset of views (where we exchanged default functionality, e.g., create views)
if model[1] == "event": if model[1] == "event":
view_name_with_prefix, url = self._name_and_url((f'admin:new_event_wizard_start', {})) _, url = self._name_and_url(("admin:new_event_wizard_start", {}))
elif model[1] == "room": elif model[1] == "room":
view_name_with_prefix, url = self._name_and_url((f'admin:room-new', {})) _, url = self._name_and_url(("admin:room-new", {}))
# Otherwise, just call the creation form view
else: else:
view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {})) _, url = self._name_and_url((f"admin:AKModel_{model[1]}_add", {}))
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken") self.assertEqual(
response.status_code,
200,
msg=f"Add form for model {model[1]} ({url}) broken",
)
for model in self.ADMIN_MODELS: for model in self.ADMIN_MODELS:
# Test the update view using the first existing instance of each model
m = model[0].objects.first() m = model[0].objects.first()
if m is not None: if m is not None:
view_name_with_prefix, url = self._name_and_url((f'admin:AKModel_{model[1]}_change', {'object_id': m.pk})) _, url = self._name_and_url(
(f"admin:AKModel_{model[1]}_change", {"object_id": m.pk})
)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken") self.assertEqual(
response.status_code,
200,
msg=f"Edit form for model {model[1]} ({url}) broken",
)
def test_wiki_export(self): def test_wiki_export(self):
"""
Test wiki export
This will test whether the view renders at all and whether the export list contains the correct AKs
"""
self.client.force_login(self.admin_user) self.client.force_login(self.admin_user)
export_url = reverse_lazy(f"admin:ak_wiki_export", kwargs={'slug': 'kif42'}) export_url = reverse_lazy("admin:ak_wiki_export", kwargs={"slug": "kif42"})
response = self.client.get(export_url) response = self.client.get(export_url)
self.assertEqual(response.status_code, 200, "Export not working at all") self.assertEqual(response.status_code, 200, "Export not working at all")
export_count = 0 export_count = 0
for category, aks in response.context["categories_with_aks"]: for _, aks in response.context["categories_with_aks"]:
for ak in aks: for ak in aks:
self.assertEqual(ak.include_in_export, True, f"AK with export flag set to False (pk={ak.pk}) included in export") self.assertEqual(
self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export") ak.include_in_export,
True,
f"AK with export flag set to False (pk={ak.pk}) included in export",
)
self.assertNotEqual(
ak.pk,
1,
"AK known to be excluded from export (PK 1) included in export",
)
export_count += 1 export_count += 1
self.assertEqual(export_count, AK.objects.filter(event_id=2, include_in_export=True).count(), self.assertEqual(
"Wiki export contained the wrong number of AKs") export_count,
AK.objects.filter(event_id=2, include_in_export=True).count(),
"Wiki export contained the wrong number of AKs",
)
...@@ -4,12 +4,16 @@ from django.urls import include, path ...@@ -4,12 +4,16 @@ from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
import AKModel.views.api import AKModel.views.api
from AKModel.views.manage import ExportSlidesView from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView AKsByUserView, AKScheduleJSONImportView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \
AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \ from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView
from AKModel.views.room import RoomBatchCreationView
from AKModel.views.status import EventStatusView from AKModel.views.status import EventStatusView
# Register basic API views/endpoints
api_router = DefaultRouter() api_router = DefaultRouter()
api_router.register('akowner', AKModel.views.api.AKOwnerViewSet, basename='AKOwner') api_router.register('akowner', AKModel.views.api.AKOwnerViewSet, basename='AKOwner')
api_router.register('akcategory', AKModel.views.api.AKCategoryViewSet, basename='AKCategory') api_router.register('akcategory', AKModel.views.api.AKCategoryViewSet, basename='AKCategory')
...@@ -18,7 +22,9 @@ api_router.register('ak', AKModel.views.api.AKViewSet, basename='AK') ...@@ -18,7 +22,9 @@ api_router.register('ak', AKModel.views.api.AKViewSet, basename='AK')
api_router.register('room', AKModel.views.api.RoomViewSet, basename='Room') api_router.register('room', AKModel.views.api.RoomViewSet, basename='Room')
api_router.register('akslot', AKModel.views.api.AKSlotViewSet, basename='AKSlot') api_router.register('akslot', AKModel.views.api.AKSlotViewSet, basename='AKSlot')
# TODO Can we move this functionality to the individual apps instead?
extra_paths = [] extra_paths = []
# If AKScheduling is active, register additional API endpoints
if apps.is_installed("AKScheduling"): if apps.is_installed("AKScheduling"):
from AKScheduling.api import ResourcesViewSet, RoomAvailabilitiesView, EventsView, EventsViewSet, \ from AKScheduling.api import ResourcesViewSet, RoomAvailabilitiesView, EventsView, EventsViewSet, \
ConstraintViolationsViewSet, DefaultSlotsView ConstraintViolationsViewSet, DefaultSlotsView
...@@ -33,9 +39,10 @@ if apps.is_installed("AKScheduling"): ...@@ -33,9 +39,10 @@ if apps.is_installed("AKScheduling"):
name='scheduling-room-availabilities')), name='scheduling-room-availabilities')),
extra_paths.append(path('api/scheduling-default-slots/', DefaultSlotsView.as_view(), extra_paths.append(path('api/scheduling-default-slots/', DefaultSlotsView.as_view(),
name='scheduling-default-slots')) name='scheduling-default-slots'))
#If AKSubmission is active, register an additional API endpoint for increasing the interest counter
if apps.is_installed("AKSubmission"): if apps.is_installed("AKSubmission"):
from AKSubmission.api import increment_interest_counter from AKSubmission.api import increment_interest_counter
extra_paths.append(path('api/ak/<pk>/indicate-interest/', increment_interest_counter, name='submission-ak-indicate-interest')) extra_paths.append(path('api/ak/<pk>/indicate-interest/', increment_interest_counter, name='submission-ak-indicate-interest'))
event_specific_paths = [ event_specific_paths = [
...@@ -45,6 +52,7 @@ event_specific_paths.extend(extra_paths) ...@@ -45,6 +52,7 @@ event_specific_paths.extend(extra_paths)
app_name = 'model' app_name = 'model'
# Included all these extra view paths at a path starting with the event slug
urlpatterns = [ urlpatterns = [
path( path(
'<slug:event_slug>/', '<slug:event_slug>/',
...@@ -55,6 +63,9 @@ urlpatterns = [ ...@@ -55,6 +63,9 @@ urlpatterns = [
def get_admin_urls_event_wizard(admin_site): def get_admin_urls_event_wizard(admin_site):
"""
Defines all additional URLs for the event creation wizard
"""
return [ return [
path('add/wizard/start/', admin_site.admin_view(NewEventWizardStartView.as_view()), path('add/wizard/start/', admin_site.admin_view(NewEventWizardStartView.as_view()),
name="new_event_wizard_start"), name="new_event_wizard_start"),
...@@ -75,15 +86,30 @@ def get_admin_urls_event_wizard(admin_site): ...@@ -75,15 +86,30 @@ def get_admin_urls_event_wizard(admin_site):
def get_admin_urls_event(admin_site): def get_admin_urls_event(admin_site):
"""
Defines all additional event-related view URLs that will be included in the event admin interface
"""
return [ return [
path('<slug:event_slug>/status/', admin_site.admin_view(EventStatusView.as_view()), name="event_status"), path('<slug:event_slug>/status/', admin_site.admin_view(EventStatusView.as_view()), name="event_status"),
path('<slug:event_slug>/requirements/', admin_site.admin_view(AKRequirementOverview.as_view()), path('<slug:event_slug>/requirements/', admin_site.admin_view(AKRequirementOverview.as_view()),
name="event_requirement_overview"), name="event_requirement_overview"),
path('<slug:event_slug>/aks/owner/<pk>/', admin_site.admin_view(AKsByUserView.as_view()),
name="aks_by_owner"),
path('<slug:event_slug>/ak-csv-export/', admin_site.admin_view(AKCSVExportView.as_view()), path('<slug:event_slug>/ak-csv-export/', admin_site.admin_view(AKCSVExportView.as_view()),
name="ak_csv_export"), name="ak_csv_export"),
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"),
path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()), path('<slug:slug>/ak-wiki-export/', admin_site.admin_view(AKWikiExportView.as_view()),
name="ak_wiki_export"), name="ak_wiki_export"),
path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()), path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()),
name="ak_delete_orga_messages"), name="ak_delete_orga_messages"),
path('<slug:event_slug>/ak-slide-export/', admin_site.admin_view(ExportSlidesView.as_view()), name="ak_slide_export"), path('<slug:event_slug>/ak-slide-export/', admin_site.admin_view(ExportSlidesView.as_view()), name="ak_slide_export"),
path('plan/publish/', admin_site.admin_view(PlanPublishView.as_view()), name="plan-publish"),
path('plan/unpublish/', admin_site.admin_view(PlanUnpublishView.as_view()), name="plan-unpublish"),
path('<slug:event_slug>/defaultSlots/', admin_site.admin_view(DefaultSlotEditorView.as_view()),
name="default-slots-editor"),
path('<slug:event_slug>/importRooms/', admin_site.admin_view(RoomBatchCreationView.as_view()),
name="room-import"),
] ]
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.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, DetailView from django.views.generic import ListView, DetailView
...@@ -9,6 +12,9 @@ from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK ...@@ -9,6 +12,9 @@ from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK
class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Display requirements for the given event
"""
model = AKRequirement model = AKRequirement
context_object_name = "requirements" context_object_name = "requirements"
title = _("Requirements for Event") title = _("Requirements for Event")
...@@ -22,6 +28,9 @@ class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -22,6 +28,9 @@ class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Export all AK slots of this event in CSV format ordered by tracks
"""
template_name = "admin/AKModel/ak_csv_export.html" template_name = "admin/AKModel/ak_csv_export.html"
model = AKSlot model = AKSlot
context_object_name = "slots" context_object_name = "slots"
...@@ -30,12 +39,50 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView): ...@@ -30,12 +39,50 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().order_by("ak__track") return super().get_queryset().order_by("ak__track")
class AKJSONExportView(AdminViewMixin, DetailView):
"""
View: Export all AK slots of this event in JSON format ordered by tracks
"""
template_name = "admin/AKModel/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): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
try:
data = context["event"].as_json_dict()
context["json_data_oneline"] = json.dumps(data, ensure_ascii=False)
context["json_data"] = json.dumps(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 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 AKWikiExportView(AdminViewMixin, DetailView): class AKWikiExportView(AdminViewMixin, DetailView):
"""
View: Export AKs of this event in wiki syntax
This will show one text field per category, with a separate category/field for wishes
"""
template_name = "admin/AKModel/wiki_export.html" template_name = "admin/AKModel/wiki_export.html"
model = Event model = Event
context_object_name = "event" context_object_name = "event"
...@@ -46,7 +93,7 @@ class AKWikiExportView(AdminViewMixin, DetailView): ...@@ -46,7 +93,7 @@ class AKWikiExportView(AdminViewMixin, DetailView):
categories_with_aks, ak_wishes = context["event"].get_categories_with_aks( categories_with_aks, ak_wishes = context["event"].get_categories_with_aks(
wishes_seperately=True, wishes_seperately=True,
filter=lambda ak: ak.include_in_export filter_func=lambda ak: ak.include_in_export
) )
context["categories_with_aks"] = [(category.name, ak_list) for category, ak_list in categories_with_aks] context["categories_with_aks"] = [(category.name, ak_list) for category, ak_list in categories_with_aks]
...@@ -56,10 +103,18 @@ class AKWikiExportView(AdminViewMixin, DetailView): ...@@ -56,10 +103,18 @@ class AKWikiExportView(AdminViewMixin, DetailView):
class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView): class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView):
"""
View: Confirmation page to delete confidential AK-related messages to orga
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
template_name = "admin/AKModel/message_delete.html" template_name = "admin/AKModel/message_delete.html"
title = _("Delete AK Orga Messages") title = _("Delete AK Orga Messages")
def get_orga_messages_for_event(self, event): def get_orga_messages_for_event(self, event):
"""
Get all orga messages for the given event
"""
return AKOrgaMessage.objects.filter(ak__event=event) return AKOrgaMessage.objects.filter(ak__event=event)
def get_success_url(self): def get_success_url(self):
...@@ -77,6 +132,11 @@ class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView): ...@@ -77,6 +132,11 @@ class AKMessageDeleteView(EventSlugMixin, IntermediateAdminView):
class AKResetInterestView(IntermediateAdminActionView): class AKResetInterestView(IntermediateAdminActionView):
"""
View: Confirmation page to reset all manually specified interest values
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
title = _("Reset interest in AKs") title = _("Reset interest in AKs")
model = AK model = AK
confirmation_message = _("Interest of the following AKs will be set to not filled (-1):") confirmation_message = _("Interest of the following AKs will be set to not filled (-1):")
...@@ -87,6 +147,11 @@ class AKResetInterestView(IntermediateAdminActionView): ...@@ -87,6 +147,11 @@ class AKResetInterestView(IntermediateAdminActionView):
class AKResetInterestCounterView(IntermediateAdminActionView): class AKResetInterestCounterView(IntermediateAdminActionView):
"""
View: Confirmation page to reset all interest counters (online interest indication)
Confirmation functionality provided by :class:`AKModel.metaviews.admin.IntermediateAdminView`
"""
title = _("Reset AKs' interest counters") title = _("Reset AKs' interest counters")
model = AK model = AK
confirmation_message = _("Interest counter of the following AKs will be set to 0:") confirmation_message = _("Interest counter of the following AKs will be set to 0:")
......
...@@ -7,6 +7,10 @@ from AKModel.serializers import AKOwnerSerializer, AKCategorySerializer, AKTrack ...@@ -7,6 +7,10 @@ from AKModel.serializers import AKOwnerSerializer, AKCategorySerializer, AKTrack
class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Owners (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKOwnerSerializer serializer_class = AKOwnerSerializer
...@@ -15,6 +19,10 @@ class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModel ...@@ -15,6 +19,10 @@ class AKOwnerViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModel
class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Categories (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKCategorySerializer serializer_class = AKCategorySerializer
...@@ -24,6 +32,10 @@ class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListMo ...@@ -24,6 +32,10 @@ class AKCategoryViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListMo
class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin,
mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): mixins.DestroyModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Tracks (restricted to those of the given event)
Read, Write, Delete
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKTrackSerializer serializer_class = AKTrackSerializer
...@@ -33,6 +45,10 @@ class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateMod ...@@ -33,6 +45,10 @@ class AKTrackViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateMod
class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin, class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet):
"""
API View: AKs (restricted to those of the given event)
Read, Write
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKSerializer serializer_class = AKSerializer
...@@ -41,6 +57,10 @@ class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMix ...@@ -41,6 +57,10 @@ class AKViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.UpdateModelMix
class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: Rooms (restricted to those of the given event)
Read-only
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = RoomSerializer serializer_class = RoomSerializer
...@@ -50,6 +70,10 @@ class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMix ...@@ -50,6 +70,10 @@ class RoomViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.ListModelMix
class AKSlotViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, class AKSlotViewSet(EventSlugMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin,
mixins.ListModelMixin, viewsets.GenericViewSet): mixins.ListModelMixin, viewsets.GenericViewSet):
"""
API View: AK slots (restricted to those of the given event)
Read, Write
"""
permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,) permission_classes = (permissions.DjangoModelPermissionsOrAnonReadOnly,)
serializer_class = AKSlotSerializer serializer_class = AKSlotSerializer
......
...@@ -12,6 +12,12 @@ from AKModel.models import Event ...@@ -12,6 +12,12 @@ from AKModel.models import Event
class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView): class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView):
"""
Wizard view: Entry/Start
Specify basic settings, especially the timezone for correct time treatment in the next view
(:class:`NewEventWizardSettingsView`) where this view will redirect to without saving the new event already
"""
model = Event model = Event
form_class = NewEventWizardStartForm form_class = NewEventWizardStartForm
template_name = "admin/AKModel/event_wizard/start.html" template_name = "admin/AKModel/event_wizard/start.html"
...@@ -19,6 +25,16 @@ class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView): ...@@ -19,6 +25,16 @@ class NewEventWizardStartView(AdminViewMixin, WizardViewMixin, CreateView):
class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView): class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView):
"""
Wizard view: Event settings
Specify most of the event settings. The user will see that certain fields are required since they were lead here
from another form in :class:`NewEventWizardStartView` that did not contain these fields even though they are
mandatory for the event model
Next step will then be :class:`NewEventWizardPrepareImportView` to prepare copy configuration elements
from an existing event
"""
model = Event model = Event
form_class = NewEventWizardSettingsForm form_class = NewEventWizardSettingsForm
template_name = "admin/AKModel/event_wizard/settings.html" template_name = "admin/AKModel/event_wizard/settings.html"
...@@ -34,6 +50,14 @@ class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView): ...@@ -34,6 +50,14 @@ class NewEventWizardSettingsView(AdminViewMixin, WizardViewMixin, CreateView):
class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView): class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView):
"""
Wizard view: Choose event to copy configuration elements from
The user can here select an existing event to copy elements like requirements, categories and dashboard buttons from
The exact subset of elements to copy from can then be selected in the next view (:class:`NewEventWizardImportView`)
Instead, this step can be skipped by directly continuing with :class:`NewEventWizardActivateView`
"""
form_class = NewEventWizardPrepareImportForm form_class = NewEventWizardPrepareImportForm
template_name = "admin/AKModel/event_wizard/created_prepare_import.html" template_name = "admin/AKModel/event_wizard/created_prepare_import.html"
wizard_step = 3 wizard_step = 3
...@@ -45,29 +69,40 @@ class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView) ...@@ -45,29 +69,40 @@ class NewEventWizardPrepareImportView(WizardViewMixin, EventSlugMixin, FormView)
class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView): class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView):
"""
Wizard view: Select configuration elements to copy
Displays lists of requirements, categories and dashboard buttons that the user can select entries to be copied from
Afterwards, the event can be activated in :class:`NewEventWizardActivateView`
"""
form_class = NewEventWizardImportForm form_class = NewEventWizardImportForm
template_name = "admin/AKModel/event_wizard/import.html" template_name = "admin/AKModel/event_wizard/import.html"
wizard_step = 4 wizard_step = 4
def get_initial(self): def get_initial(self):
initial = super().get_initial() initial = super().get_initial()
# Remember which event was selected and send it again when submitting the form for validation
initial["import_event"] = Event.objects.get(slug=self.kwargs["import_slug"]) initial["import_event"] = Event.objects.get(slug=self.kwargs["import_slug"])
return initial return initial
def form_valid(self, form): def form_valid(self, form):
# pylint: disable=consider-using-f-string
import_types = ["import_categories", "import_requirements"] import_types = ["import_categories", "import_requirements"]
if apps.is_installed("AKDashboard"): if apps.is_installed("AKDashboard"):
import_types.append("import_buttons") import_types.append("import_buttons")
# Loop over all kinds of configuration elements and then over all selected elements of each type
# and try to clone them by requesting a new primary key, adapting the event and then storing the
# object in the database
for import_type in import_types: for import_type in import_types:
for import_obj in form.cleaned_data.get(import_type): for import_obj in form.cleaned_data.get(import_type):
# clone existing entry
try: try:
import_obj.event = self.event import_obj.event = self.event
import_obj.pk = None import_obj.pk = None
import_obj.save() import_obj.save()
messages.add_message(self.request, messages.SUCCESS, _("Copied '%(obj)s'" % {'obj': import_obj})) messages.add_message(self.request, messages.SUCCESS, _("Copied '%(obj)s'" % {'obj': import_obj}))
except BaseException as e: except BaseException as e: # pylint: disable=broad-exception-caught
messages.add_message(self.request, messages.ERROR, messages.add_message(self.request, messages.ERROR,
_("Could not copy '%(obj)s' (%(error)s)" % {'obj': import_obj, _("Could not copy '%(obj)s' (%(error)s)" % {'obj': import_obj,
"error": str(e)})) "error": str(e)}))
...@@ -75,6 +110,17 @@ class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView): ...@@ -75,6 +110,17 @@ class NewEventWizardImportView(EventSlugMixin, WizardViewMixin, FormView):
class NewEventWizardActivateView(WizardViewMixin, UpdateView): class NewEventWizardActivateView(WizardViewMixin, UpdateView):
"""
Wizard view: Allow activating the event
The user is asked to make the created event active. This is done in this step and not already during the creation
in the second step of the wizard to prevent users seeing an unconfigured submission.
The event will nevertheless already be visible in the dashboard before, when a public event was created in
:class:`NewEventWizardSettingsView`.
In the following last step (:class:`NewEventWizardFinishView`), a confirmation of the full process and some
details of the created event are shown
"""
model = Event model = Event
template_name = "admin/AKModel/event_wizard/activate.html" template_name = "admin/AKModel/event_wizard/activate.html"
form_class = NewEventWizardActivateForm form_class = NewEventWizardActivateForm
...@@ -85,6 +131,11 @@ class NewEventWizardActivateView(WizardViewMixin, UpdateView): ...@@ -85,6 +131,11 @@ class NewEventWizardActivateView(WizardViewMixin, UpdateView):
class NewEventWizardFinishView(WizardViewMixin, DetailView): class NewEventWizardFinishView(WizardViewMixin, DetailView):
"""
Wizard view: Confirmation and summary
Show a confirmation and a summary of the created event
"""
model = Event model = Event
template_name = "admin/AKModel/event_wizard/finish.html" template_name = "admin/AKModel/event_wizard/finish.html"
wizard_step = 6 wizard_step = 6
...@@ -4,30 +4,44 @@ import os ...@@ -4,30 +4,44 @@ import os
import tempfile import tempfile
from itertools import zip_longest from itertools import zip_longest
from django.contrib import messages from django.contrib import messages
from django.db.models.functions import Now from django.db.models.functions import Now
from django.shortcuts import redirect
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView from django.views.generic import TemplateView, DetailView
from django_tex.core import render_template_with_context, run_tex_in_directory from django_tex.core import render_template_with_context, run_tex_in_directory
from django_tex.response import PDFResponse from django_tex.response import PDFResponse
from AKModel.forms import SlideExportForm, DefaultSlotEditorForm from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONScheduleImportForm
from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin
from AKModel.models import ConstraintViolation, Event, DefaultSlot from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner
class UserView(TemplateView): class UserView(TemplateView):
"""
View: Start page for logged in user
Will over a link to backend or inform the user that their account still needs to be confirmed
"""
template_name = "AKModel/user.html" template_name = "AKModel/user.html"
class ExportSlidesView(EventSlugMixin, IntermediateAdminView): class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
"""
View: Export slides to present AKs
Over a form to choose some settings for the export and then generate the PDF
"""
title = _('Export AK Slides') title = _('Export AK Slides')
form_class = SlideExportForm form_class = SlideExportForm
def form_valid(self, form): def form_valid(self, form):
# pylint: disable=invalid-name
template_name = 'admin/AKModel/export/slides.tex' template_name = 'admin/AKModel/export/slides.tex'
# Settings
NEXT_AK_LIST_LENGTH = form.cleaned_data['num_next'] NEXT_AK_LIST_LENGTH = form.cleaned_data['num_next']
RESULT_PRESENTATION_MODE = form.cleaned_data["presentation_mode"] RESULT_PRESENTATION_MODE = form.cleaned_data["presentation_mode"]
SPACE_FOR_NOTES_IN_WISHES = form.cleaned_data["wish_notes"] SPACE_FOR_NOTES_IN_WISHES = form.cleaned_data["wish_notes"]
...@@ -42,12 +56,18 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): ...@@ -42,12 +56,18 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
} }
def build_ak_list_with_next_aks(ak_list): def build_ak_list_with_next_aks(ak_list):
"""
Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting)
"""
next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None) next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None)
return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=list())] return list(zip_longest(ak_list, next_aks_list, fillvalue=[]))
categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter=lambda # Get all relevant AKs (wishes separately, and either all AKs or only those who should directly or indirectly
# be presented when restriction setting was chosen)
categories_with_aks, ak_wishes = self.event.get_categories_with_aks(wishes_seperately=True, filter_func=lambda
ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default))) ak: not RESULT_PRESENTATION_MODE or (ak.present or (ak.present is None and ak.category.present_by_default)))
# Create context for LaTeX rendering
context = { context = {
'title': self.event.name, 'title': self.event.name,
'categories_with_aks': [(category, build_ak_list_with_next_aks(ak_list)) for category, ak_list in 'categories_with_aks': [(category, build_ak_list_with_next_aks(ak_list)) for category, ak_list in
...@@ -67,11 +87,17 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView): ...@@ -67,11 +87,17 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
os.remove(f'{tempdir}/texput.tex') os.remove(f'{tempdir}/texput.tex')
pdf = run_tex_in_directory(source, tempdir, template_name=self.template_name) pdf = run_tex_in_directory(source, tempdir, template_name=self.template_name)
# Show PDF file to the user (with a filename containing a timestamp to prevent confusions about the right
# version to use when generating multiple versions of the slides, e.g., because owners did last-minute changes
# to their AKs
timestamp = datetime.datetime.now(tz=self.event.timezone).strftime("%Y-%m-%d_%H_%M") timestamp = datetime.datetime.now(tz=self.event.timezone).strftime("%Y-%m-%d_%H_%M")
return PDFResponse(pdf, filename=f'{self.event.slug}_ak_slides_{timestamp}.pdf') return PDFResponse(pdf, filename=f'{self.event.slug}_ak_slides_{timestamp}.pdf')
class CVMarkResolvedView(IntermediateAdminActionView): class CVMarkResolvedView(IntermediateAdminActionView):
"""
Admin action view: Mark one or multitple constraint violation(s) as resolved
"""
title = _('Mark Constraint Violations as manually resolved') title = _('Mark Constraint Violations as manually resolved')
model = ConstraintViolation model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be marked as manually resolved") confirmation_message = _("The following Constraint Violations will be marked as manually resolved")
...@@ -82,6 +108,9 @@ class CVMarkResolvedView(IntermediateAdminActionView): ...@@ -82,6 +108,9 @@ class CVMarkResolvedView(IntermediateAdminActionView):
class CVSetLevelViolationView(IntermediateAdminActionView): class CVSetLevelViolationView(IntermediateAdminActionView):
"""
Admin action view: Set one or multitple constraint violation(s) as to level "violation"
"""
title = _('Set Constraint Violations to level "violation"') title = _('Set Constraint Violations to level "violation"')
model = ConstraintViolation model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'violation'") confirmation_message = _("The following Constraint Violations will be set to level 'violation'")
...@@ -92,6 +121,9 @@ class CVSetLevelViolationView(IntermediateAdminActionView): ...@@ -92,6 +121,9 @@ class CVSetLevelViolationView(IntermediateAdminActionView):
class CVSetLevelWarningView(IntermediateAdminActionView): class CVSetLevelWarningView(IntermediateAdminActionView):
"""
Admin action view: Set one or multitple constraint violation(s) as to level "warning"
"""
title = _('Set Constraint Violations to level "warning"') title = _('Set Constraint Violations to level "warning"')
model = ConstraintViolation model = ConstraintViolation
confirmation_message = _("The following Constraint Violations will be set to level 'warning'") confirmation_message = _("The following Constraint Violations will be set to level 'warning'")
...@@ -102,6 +134,9 @@ class CVSetLevelWarningView(IntermediateAdminActionView): ...@@ -102,6 +134,9 @@ class CVSetLevelWarningView(IntermediateAdminActionView):
class PlanPublishView(IntermediateAdminActionView): class PlanPublishView(IntermediateAdminActionView):
"""
Admin action view: Publish the plan of one or multitple event(s)
"""
title = _('Publish plan') title = _('Publish plan')
model = Event model = Event
confirmation_message = _('Publish the plan(s) of:') confirmation_message = _('Publish the plan(s) of:')
...@@ -112,6 +147,9 @@ class PlanPublishView(IntermediateAdminActionView): ...@@ -112,6 +147,9 @@ class PlanPublishView(IntermediateAdminActionView):
class PlanUnpublishView(IntermediateAdminActionView): class PlanUnpublishView(IntermediateAdminActionView):
"""
Admin action view: Unpublish the plan of one or multitple event(s)
"""
title = _('Unpublish plan') title = _('Unpublish plan')
model = Event model = Event
confirmation_message = _('Unpublish the plan(s) of:') confirmation_message = _('Unpublish the plan(s) of:')
...@@ -122,6 +160,9 @@ class PlanUnpublishView(IntermediateAdminActionView): ...@@ -122,6 +160,9 @@ class PlanUnpublishView(IntermediateAdminActionView):
class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
"""
Admin view: Allow to edit the default slots of an event
"""
template_name = "admin/AKModel/default_slot_editor.html" template_name = "admin/AKModel/default_slot_editor.html"
form_class = DefaultSlotEditorForm form_class = DefaultSlotEditorForm
title = _("Edit Default Slots") title = _("Edit Default Slots")
...@@ -149,13 +190,14 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): ...@@ -149,13 +190,14 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
previous_slot_ids = set(s.id for s in self.event.defaultslot_set.all()) previous_slot_ids = set(s.id for s in self.event.defaultslot_set.all())
# Loop over inputs and update or add slots
for slot in default_slots_raw: for slot in default_slots_raw:
start = parse_datetime(slot["start"]).replace(tzinfo=tz) start = parse_datetime(slot["start"]).replace(tzinfo=tz)
end = parse_datetime(slot["end"]).replace(tzinfo=tz) end = parse_datetime(slot["end"]).replace(tzinfo=tz)
if slot["id"] != '': if slot["id"] != '':
id = int(slot["id"]) slot_id = int(slot["id"])
if id not in previous_slot_ids: if slot_id not in previous_slot_ids:
# Make sure only slots (currently) belonging to this event are edited # Make sure only slots (currently) belonging to this event are edited
# (user did not manipulate IDs and slots have not been deleted in another session in the meantime) # (user did not manipulate IDs and slots have not been deleted in another session in the meantime)
messages.add_message( messages.add_message(
...@@ -166,8 +208,8 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): ...@@ -166,8 +208,8 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
) )
else: else:
# Update existing entries # Update existing entries
previous_slot_ids.remove(id) previous_slot_ids.remove(slot_id)
original_slot = DefaultSlot.objects.get(id=id) original_slot = DefaultSlot.objects.get(id=slot_id)
if original_slot.start != start or original_slot.end != end: if original_slot.start != start or original_slot.end != end:
original_slot.start = start original_slot.start = start
original_slot.end = end original_slot.end = end
...@@ -187,6 +229,7 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): ...@@ -187,6 +229,7 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
for d_id in previous_slot_ids: for d_id in previous_slot_ids:
DefaultSlot.objects.get(id=d_id).delete() DefaultSlot.objects.get(id=d_id).delete()
# Inform user about changes performed
if created_count + updated_count + deleted_count > 0: if created_count + updated_count + deleted_count > 0:
messages.add_message( messages.add_message(
self.request, self.request,
...@@ -195,3 +238,38 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView): ...@@ -195,3 +238,38 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
.format(u=str(updated_count), c=str(created_count), d=str(deleted_count)) .format(u=str(updated_count), c=str(created_count), d=str(deleted_count))
) )
return super().form_valid(form) return super().form_valid(form)
class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView):
"""
View: Show all AKs of a given user
"""
model = AKOwner
context_object_name = 'owner'
template_name = "admin/AKModel/aks_by_user.html"
class AKScheduleJSONImportView(EventSlugMixin, IntermediateAdminView):
"""
View: Import an AK schedule from a json file that can be pasted into this view.
"""
template_name = "admin/AKModel/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)
...@@ -15,6 +15,9 @@ from AKModel.models import Room ...@@ -15,6 +15,9 @@ from AKModel.models import Room
class RoomCreationView(AdminViewMixin, CreateView): class RoomCreationView(AdminViewMixin, CreateView):
"""
Admin view: Create a room
"""
form_class = RoomForm form_class = RoomForm
template_name = 'admin/AKModel/room_create.html' template_name = 'admin/AKModel/room_create.html'
...@@ -22,18 +25,28 @@ class RoomCreationView(AdminViewMixin, CreateView): ...@@ -22,18 +25,28 @@ class RoomCreationView(AdminViewMixin, CreateView):
print(self.request.POST['save_action']) print(self.request.POST['save_action'])
if self.request.POST['save_action'] == 'save_add_another': if self.request.POST['save_action'] == 'save_add_another':
return reverse_lazy('admin:room-new') return reverse_lazy('admin:room-new')
elif self.request.POST['save_action'] == 'save_continue': if self.request.POST['save_action'] == 'save_continue':
return reverse_lazy('admin:AKModel_room_change', kwargs={'object_id': self.room.pk}) return reverse_lazy('admin:AKModel_room_change', kwargs={'object_id': self.room.pk})
else: return reverse_lazy('admin:AKModel_room_changelist')
return reverse_lazy('admin:AKModel_room_changelist')
def form_valid(self, form): def form_valid(self, form):
self.room = form.save() self.room = form.save() # pylint: disable=attribute-defined-outside-init
# translatable string with placeholders, no f-string possible
# pylint: disable=consider-using-f-string
messages.success(self.request, _("Created Room '%(room)s'" % {'room': self.room})) messages.success(self.request, _("Created Room '%(room)s'" % {'room': self.room}))
return HttpResponseRedirect(self.get_success_url()) return HttpResponseRedirect(self.get_success_url())
class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView):
"""
Admin action: Allow to create rooms in batch by inputing a CSV-formatted list of room details into a textbox
This offers the input form, supports creation of virtual rooms if AKOnline is active, too,
and users can specify that default availabilities (from event start to end) should be created for the rooms
automatically
"""
form_class = RoomBatchCreationForm form_class = RoomBatchCreationForm
title = _("Import Rooms from CSV") title = _("Import Rooms from CSV")
...@@ -47,23 +60,33 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): ...@@ -47,23 +60,33 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView):
rooms_raw_dict: csv.DictReader = form.cleaned_data["rooms"] rooms_raw_dict: csv.DictReader = form.cleaned_data["rooms"]
# Prepare creation of virtual rooms if there is information (an URL) in the data and the AKOnline app is active
if apps.is_installed("AKOnline") and "url" in rooms_raw_dict.fieldnames: if apps.is_installed("AKOnline") and "url" in rooms_raw_dict.fieldnames:
virtual_rooms_support = True virtual_rooms_support = True
# pylint: disable=import-outside-toplevel
from AKOnline.models import VirtualRoom from AKOnline.models import VirtualRoom
# Loop over all inputs
for raw_room in rooms_raw_dict: for raw_room in rooms_raw_dict:
# Gather the relevant information (most fields can be empty)
name = raw_room["name"] name = raw_room["name"]
location = raw_room["location"] if "location" in rooms_raw_dict.fieldnames else "" location = raw_room["location"] if "location" in rooms_raw_dict.fieldnames else ""
capacity = raw_room["capacity"] if "capacity" in rooms_raw_dict.fieldnames else -1 capacity = raw_room["capacity"] if "capacity" in rooms_raw_dict.fieldnames else -1
try: try:
# Try to create a room (catches cases where the room name contains keywords or symbols that the
# database cannot handle (.e.g., special UTF-8 characters)
r = Room.objects.create(name=name, r = Room.objects.create(name=name,
location=location, location=location,
capacity=capacity, capacity=capacity,
event=self.event) event=self.event)
# and if necessary an associated virtual room, too
if virtual_rooms_support and raw_room["url"] != "": if virtual_rooms_support and raw_room["url"] != "":
VirtualRoom.objects.create(room=r, VirtualRoom.objects.create(room=r,
url=raw_room["url"]) url=raw_room["url"])
# If user requested default availabilities, create them
if create_default_availabilities: if create_default_availabilities:
a = Availability.with_event_length(event=self.event, room=r) a = Availability.with_event_length(event=self.event, room=r)
a.save() a.save()
...@@ -72,6 +95,7 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView): ...@@ -72,6 +95,7 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView):
messages.add_message(self.request, messages.WARNING, messages.add_message(self.request, messages.WARNING,
_("Could not import room {name}: {e}").format(name=name, e=str(e))) _("Could not import room {name}: {e}").format(name=name, e=str(e)))
# Inform the user about the rooms created
if created_count > 0: if created_count > 0:
messages.add_message(self.request, messages.SUCCESS, messages.add_message(self.request, messages.SUCCESS,
_("Imported {count} room(s)").format(count=created_count)) _("Imported {count} room(s)").format(count=created_count))
......
from django.apps import apps from django.apps import apps
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from AKModel.metaviews import status_manager from AKModel.metaviews import status_manager
from AKModel.metaviews.admin import EventSlugMixin, AdminViewMixin from AKModel.metaviews.admin import EventSlugMixin
from AKModel.metaviews.status import TemplateStatusWidget, StatusView from AKModel.metaviews.status import TemplateStatusWidget, StatusView
@status_manager.register(name="event_overview") @status_manager.register(name="event_overview")
class EventOverviewWidget(TemplateStatusWidget): class EventOverviewWidget(TemplateStatusWidget):
"""
Status page widget: Event overview
"""
required_context_type = "event" required_context_type = "event"
title = _("Overview") title = _("Overview")
template_name = "admin/AKModel/status/event_overview.html" template_name = "admin/AKModel/status/event_overview.html"
...@@ -20,6 +22,12 @@ class EventOverviewWidget(TemplateStatusWidget): ...@@ -20,6 +22,12 @@ class EventOverviewWidget(TemplateStatusWidget):
@status_manager.register(name="event_categories") @status_manager.register(name="event_categories")
class EventCategoriesWidget(TemplateStatusWidget): class EventCategoriesWidget(TemplateStatusWidget):
"""
Status page widget: Category information
Show all categories of the event together with the number of AKs belonging to this category.
Offers an action to add a new category.
"""
required_context_type = "event" required_context_type = "event"
title = _("Categories") title = _("Categories")
template_name = "admin/AKModel/status/event_categories.html" template_name = "admin/AKModel/status/event_categories.html"
...@@ -31,7 +39,8 @@ class EventCategoriesWidget(TemplateStatusWidget): ...@@ -31,7 +39,8 @@ class EventCategoriesWidget(TemplateStatusWidget):
] ]
def render_title(self, context: {}) -> str: def render_title(self, context: {}) -> str:
self.category_count = context['event'].akcategory_set.count() # Store category count as instance variable for re-use in body
self.category_count = context['event'].akcategory_set.count() # pylint: disable=attribute-defined-outside-init
return f"{super().render_title(context)} ({self.category_count})" return f"{super().render_title(context)} ({self.category_count})"
def render_status(self, context: {}) -> str: def render_status(self, context: {}) -> str:
...@@ -40,6 +49,12 @@ class EventCategoriesWidget(TemplateStatusWidget): ...@@ -40,6 +49,12 @@ class EventCategoriesWidget(TemplateStatusWidget):
@status_manager.register(name="event_rooms") @status_manager.register(name="event_rooms")
class EventRoomsWidget(TemplateStatusWidget): class EventRoomsWidget(TemplateStatusWidget):
"""
Status page widget: Category information
Show all rooms of the event.
Offers actions to add a single new room as well as for batch creation.
"""
required_context_type = "event" required_context_type = "event"
title = _("Rooms") title = _("Rooms")
template_name = "admin/AKModel/status/event_rooms.html" template_name = "admin/AKModel/status/event_rooms.html"
...@@ -51,7 +66,8 @@ class EventRoomsWidget(TemplateStatusWidget): ...@@ -51,7 +66,8 @@ class EventRoomsWidget(TemplateStatusWidget):
] ]
def render_title(self, context: {}) -> str: def render_title(self, context: {}) -> str:
self.room_count = context['event'].room_set.count() # Store room count as instance variable for re-use in body
self.room_count = context['event'].room_set.count() # pylint: disable=attribute-defined-outside-init
return f"{super().render_title(context)} ({self.room_count})" return f"{super().render_title(context)} ({self.room_count})"
def render_status(self, context: {}) -> str: def render_status(self, context: {}) -> str:
...@@ -59,10 +75,16 @@ class EventRoomsWidget(TemplateStatusWidget): ...@@ -59,10 +75,16 @@ class EventRoomsWidget(TemplateStatusWidget):
def render_actions(self, context: {}) -> list[dict]: def render_actions(self, context: {}) -> list[dict]:
actions = super().render_actions(context) actions = super().render_actions(context)
# Action has to be added here since it depends on the event for URL building
import_room_url = reverse_lazy("admin:room-import", kwargs={"event_slug": context["event"].slug})
for action in actions:
if action["url"] == import_room_url:
return actions
actions.append( actions.append(
{ {
"text": _("Import Rooms from CSV"), "text": _("Import Rooms from CSV"),
"url": reverse_lazy("admin:room-import", kwargs={"event_slug": context["event"].slug}), "url": import_room_url,
} }
) )
return actions return actions
...@@ -70,6 +92,12 @@ class EventRoomsWidget(TemplateStatusWidget): ...@@ -70,6 +92,12 @@ class EventRoomsWidget(TemplateStatusWidget):
@status_manager.register(name="event_aks") @status_manager.register(name="event_aks")
class EventAKsWidget(TemplateStatusWidget): class EventAKsWidget(TemplateStatusWidget):
"""
Status page widget: AK information
Show information about the AKs of this event.
Offers a long list of AK-related actions and also scheduling actions of AKScheduling is active
"""
required_context_type = "event" required_context_type = "event"
title = _("AKs") title = _("AKs")
template_name = "admin/AKModel/status/event_aks.html" template_name = "admin/AKModel/status/event_aks.html"
...@@ -88,22 +116,19 @@ class EventAKsWidget(TemplateStatusWidget): ...@@ -88,22 +116,19 @@ class EventAKsWidget(TemplateStatusWidget):
] ]
if apps.is_installed("AKScheduling"): if apps.is_installed("AKScheduling"):
actions.extend([ actions.extend([
{
"text": format_html('{} <span class="badge bg-secondary">{}</span>',
_("Constraint Violations"),
context["event"].constraintviolation_set.count()),
"url": reverse_lazy("admin:constraint-violations", kwargs={"slug": context["event"].slug}),
},
{ {
"text": _("AKs requiring special attention"), "text": _("AKs requiring special attention"),
"url": reverse_lazy("admin:special-attention", kwargs={"slug": context["event"].slug}), "url": reverse_lazy("admin:special-attention", kwargs={"slug": context["event"].slug}),
}, },
{ ])
if context["event"].ak_set.count() > 0:
actions.append({
"text": _("Enter Interest"), "text": _("Enter Interest"),
"url": reverse_lazy("admin:enter-interest", "url": reverse_lazy("admin:enter-interest",
kwargs={"event_slug": context["event"].slug, "pk": context["event"].ak_set.all().first().pk}), kwargs={"event_slug": context["event"].slug,
}, "pk": context["event"].ak_set.all().first().pk}
]) ),
})
actions.extend([ actions.extend([
{ {
"text": _("Edit Default Slots"), "text": _("Edit Default Slots"),
...@@ -113,10 +138,18 @@ class EventAKsWidget(TemplateStatusWidget): ...@@ -113,10 +138,18 @@ class EventAKsWidget(TemplateStatusWidget):
"text": _("Manage ak tracks"), "text": _("Manage ak tracks"),
"url": reverse_lazy("admin:tracks_manage", kwargs={"event_slug": context["event"].slug}), "url": reverse_lazy("admin:tracks_manage", kwargs={"event_slug": context["event"].slug}),
}, },
{
"text": _("Import AK schedule from JSON"),
"url": reverse_lazy("admin:ak_schedule_json_import", kwargs={"event_slug": context["event"].slug}),
},
{ {
"text": _("Export AKs as CSV"), "text": _("Export AKs as CSV"),
"url": reverse_lazy("admin:ak_csv_export", kwargs={"event_slug": context["event"].slug}), "url": reverse_lazy("admin:ak_csv_export", kwargs={"event_slug": context["event"].slug}),
}, },
{
"text": _("Export AKs as JSON"),
"url": reverse_lazy("admin:ak_json_export", kwargs={"event_slug": context["event"].slug}),
},
{ {
"text": _("Export AKs for Wiki"), "text": _("Export AKs for Wiki"),
"url": reverse_lazy("admin:ak_wiki_export", kwargs={"slug": context["event"].slug}), "url": reverse_lazy("admin:ak_wiki_export", kwargs={"slug": context["event"].slug}),
...@@ -132,11 +165,19 @@ class EventAKsWidget(TemplateStatusWidget): ...@@ -132,11 +165,19 @@ class EventAKsWidget(TemplateStatusWidget):
@status_manager.register(name="event_requirements") @status_manager.register(name="event_requirements")
class EventRequirementsWidget(TemplateStatusWidget): class EventRequirementsWidget(TemplateStatusWidget):
"""
Status page widget: Requirement information information
Show information about the requirements of this event.
Offers actions to add new requirements or to get a list of AKs having a given requirement.
"""
required_context_type = "event" required_context_type = "event"
title = _("Requirements") title = _("Requirements")
template_name = "admin/AKModel/status/event_requirements.html" template_name = "admin/AKModel/status/event_requirements.html"
def render_title(self, context: {}) -> str: def render_title(self, context: {}) -> str:
# Store requirements count as instance variable for re-use in body
# pylint: disable=attribute-defined-outside-init
self.requirements_count = context['event'].akrequirement_set.count() self.requirements_count = context['event'].akrequirement_set.count()
return f"{super().render_title(context)} ({self.requirements_count})" return f"{super().render_title(context)} ({self.requirements_count})"
...@@ -154,6 +195,9 @@ class EventRequirementsWidget(TemplateStatusWidget): ...@@ -154,6 +195,9 @@ class EventRequirementsWidget(TemplateStatusWidget):
class EventStatusView(EventSlugMixin, StatusView): class EventStatusView(EventSlugMixin, StatusView):
"""
View: Show a status dashboard for the given event
"""
title = _("Event Status") title = _("Event Status")
provided_context_type = "event" provided_context_type = "event"
......
...@@ -8,7 +8,7 @@ msgid "" ...@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-03-26 19:51+0200\n" "POT-Creation-Date: 2023-08-16 16:30+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
...@@ -34,7 +34,7 @@ msgstr "Raum" ...@@ -34,7 +34,7 @@ msgstr "Raum"
msgid "Virtual Room" msgid "Virtual Room"
msgstr "Virtueller Raum" msgstr "Virtueller Raum"
#: AKOnline/models.py:17 AKOnline/views.py:27 #: AKOnline/models.py:17 AKOnline/views.py:38
msgid "Virtual Rooms" msgid "Virtual Rooms"
msgstr "Virtuelle Räume" msgstr "Virtuelle Räume"
...@@ -42,12 +42,12 @@ msgstr "Virtuelle Räume" ...@@ -42,12 +42,12 @@ msgstr "Virtuelle Räume"
msgid "Leave empty if that room is not virtual/hybrid." msgid "Leave empty if that room is not virtual/hybrid."
msgstr "Leer lassen wenn der Raum nicht virtuell/hybrid ist" msgstr "Leer lassen wenn der Raum nicht virtuell/hybrid ist"
#: AKOnline/views.py:18 #: AKOnline/views.py:25
#, python-format #, python-format
msgid "Created Room '%(room)s'" msgid "Created Room '%(room)s'"
msgstr "Raum '%(room)s' angelegt" msgstr "Raum '%(room)s' angelegt"
#: AKOnline/views.py:20 #: AKOnline/views.py:28
#, python-format #, python-format
msgid "Created related Virtual Room '%(vroom)s'" msgid "Created related Virtual Room '%(vroom)s'"
msgstr "Verbundenen virtuellen Raum '%(vroom)s' angelegt" msgstr "Verbundenen virtuellen Raum '%(vroom)s' angelegt"
# Create your tests here.
# Register your models here.
# Create your models here.
...@@ -51,6 +51,8 @@ ...@@ -51,6 +51,8 @@
start: '{{ start | timezone:event.timezone | date:"Y-m-d H:i:s" }}', start: '{{ start | timezone:event.timezone | date:"Y-m-d H:i:s" }}',
end: '{{ end | timezone:event.timezone | date:"Y-m-d H:i:s"}}', end: '{{ end | timezone:event.timezone | date:"Y-m-d H:i:s"}}',
}, },
slotMinTime: '{{ earliest_start_hour }}:00:00',
slotMaxTime: '{{ latest_end_hour }}:00:00',
eventDidMount: function(info) { eventDidMount: function(info) {
$(info.el).tooltip({title: info.event.extendedProps.description}); $(info.el).tooltip({title: info.event.extendedProps.description});
}, },
......
from django.test import TestCase from django.test import TestCase
from AKModel.tests import BasicViewTests from AKModel.tests.test_views import BasicViewTests
class PlanViewTests(BasicViewTests, TestCase): class PlanViewTests(BasicViewTests, TestCase):
"""
Tests for AKPlan
"""
fixtures = ['model.json'] fixtures = ['model.json']
APP_NAME = 'plan' APP_NAME = 'plan'
...@@ -15,7 +18,10 @@ class PlanViewTests(BasicViewTests, TestCase): ...@@ -15,7 +18,10 @@ class PlanViewTests(BasicViewTests, TestCase):
] ]
def test_plan_hidden(self): def test_plan_hidden(self):
view_name_with_prefix, url = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) """
Test correct handling of plan visibility
"""
_, url = self._name_and_url(('plan_overview', {'event_slug': 'kif23'}))
self.client.logout() self.client.logout()
response = self.client.get(url) response = self.client.get(url)
...@@ -28,8 +34,11 @@ class PlanViewTests(BasicViewTests, TestCase): ...@@ -28,8 +34,11 @@ class PlanViewTests(BasicViewTests, TestCase):
msg_prefix="Plan is not visible for staff user") msg_prefix="Plan is not visible for staff user")
def test_wall_redirect(self): def test_wall_redirect(self):
view_name_with_prefix, url_wall = self._name_and_url(('plan_wall', {'event_slug': 'kif23'})) """
view_name_with_prefix, url_plan = self._name_and_url(('plan_overview', {'event_slug': 'kif23'})) Test: Make sure that user is redirected from wall to overview when plan is hidden
"""
_, url_wall = self._name_and_url(('plan_wall', {'event_slug': 'kif23'}))
_, url_plan = self._name_and_url(('plan_overview', {'event_slug': 'kif23'}))
response = self.client.get(url_wall) response = self.client.get(url_wall)
self.assertRedirects(response, url_plan, self.assertRedirects(response, url_plan,
......
from datetime import datetime, timedelta
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.datetime_safe import datetime from django.views.generic import DetailView, ListView
from django.views.generic import ListView, DetailView
from AKModel.models import AKSlot, Room, AKTrack
from AKModel.metaviews.admin import FilterByEventSlugMixin from AKModel.metaviews.admin import FilterByEventSlugMixin
from AKModel.models import AKSlot, AKTrack, Room
class PlanIndexView(FilterByEventSlugMixin, ListView): class PlanIndexView(FilterByEventSlugMixin, ListView):
...@@ -81,11 +82,12 @@ class PlanScreenView(PlanIndexView): ...@@ -81,11 +82,12 @@ class PlanScreenView(PlanIndexView):
return redirect(reverse_lazy("plan:plan_overview", kwargs={"event_slug": self.event.slug})) return redirect(reverse_lazy("plan:plan_overview", kwargs={"event_slug": self.event.slug}))
return s return s
""" # pylint: disable=attribute-defined-outside-init
def get_queryset(self): def get_queryset(self):
# Determine interesting range (some hours ago until some hours in the future as specified in the settings)
now = datetime.now().astimezone(self.event.timezone) now = datetime.now().astimezone(self.event.timezone)
# Wall during event: Adjust, show only parts in the future
if self.event.start < now < self.event.end: if self.event.start < now < self.event.end:
# Determine interesting range (some hours ago until some hours in the future as specified in the settings)
self.start = now - timedelta(hours=settings.PLAN_WALL_HOURS_RETROSPECT) self.start = now - timedelta(hours=settings.PLAN_WALL_HOURS_RETROSPECT)
else: else:
self.start = self.event.start self.start = self.event.start
...@@ -93,13 +95,31 @@ class PlanScreenView(PlanIndexView): ...@@ -93,13 +95,31 @@ class PlanScreenView(PlanIndexView):
# Restrict AK slots to relevant ones # Restrict AK slots to relevant ones
# This will automatically filter all rooms not needed for the selected range in the orginal get_context method # This will automatically filter all rooms not needed for the selected range in the orginal get_context method
return super().get_queryset().filter(start__gt=self.start) akslots = super().get_queryset().filter(start__gt=self.start)
"""
# Find the earliest hour AKs start and end (handle 00:00 as 24:00)
self.earliest_start_hour = 23
self.latest_end_hour = 1
for akslot in akslots.all():
start_hour = akslot.start.astimezone(self.event.timezone).hour
if start_hour < self.earliest_start_hour:
# Use hour - 1 to improve visibility of date change
self.earliest_start_hour = max(start_hour - 1, 0)
end_hour = akslot.end.astimezone(self.event.timezone).hour
# Special case: AK starts before but ends after midnight -- show until midnight
if end_hour < start_hour:
self.latest_end_hour = 24
elif end_hour > self.latest_end_hour:
# Always use hour + 1, since AK may end at :xy and not always at :00
self.latest_end_hour = min(end_hour + 1, 24)
return akslots
def get_context_data(self, *, object_list=None, **kwargs): def get_context_data(self, *, object_list=None, **kwargs):
context = super().get_context_data(object_list=object_list, **kwargs) context = super().get_context_data(object_list=object_list, **kwargs)
context["start"] = self.event.start context["start"] = self.start
context["end"] = self.event.end context["end"] = self.event.end
context["earliest_start_hour"] = self.earliest_start_hour
context["latest_end_hour"] = self.latest_end_hour
return context return context
...@@ -131,7 +151,7 @@ class PlanTrackView(FilterByEventSlugMixin, DetailView): ...@@ -131,7 +151,7 @@ class PlanTrackView(FilterByEventSlugMixin, DetailView):
context = super().get_context_data(object_list=object_list, **kwargs) context = super().get_context_data(object_list=object_list, **kwargs)
# Restrict AKSlot list to given track # Restrict AKSlot list to given track
# while joining AK, room and category information to reduce the amount of necessary SQL queries # while joining AK, room and category information to reduce the amount of necessary SQL queries
context["slots"] = AKSlot.objects.\ context["slots"] = AKSlot.objects. \
filter(event=self.event, ak__track=context['track']).\ filter(event=self.event, ak__track=context['track']). \
select_related('ak', 'room', 'ak__category') select_related('ak', 'room', 'ak__category')
return context return context