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

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
Show changes
Commits on Source (69)
Showing
with 2155 additions and 326 deletions
image: python:3.9
image: python:3.10
services:
- mysql
......@@ -38,7 +38,7 @@ test:
script:
- source venv/bin/activate
- echo "GRANT ALL on *.* to '${MYSQL_USER}';"| mysql -u root --password="${MYSQL_ROOT_PASSWORD}" -h mysql
- pip install pytest-cov unittest-xml-reporting
- pip install pytest-cov unittest-xml-reporting beautifulsoup4
- coverage run --source='.' manage.py test --settings AKPlanning.settings_ci
after_script:
- source venv/bin/activate
......@@ -56,6 +56,8 @@ lint:
extends: .before_script_template
stage: test
script:
- source venv/bin/activate
- pip install beautifulsoup4
- pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=text AK* | tee /tmp/pylint.txt
- sed -n 's/^Your code has been rated at \([-0-9.]*\)\/.*/\1/p' /tmp/pylint.txt > public/badges/$CI_JOB_NAME.score
- pylint --load-plugins pylint_django --django-settings-module=AKPlanning.settings_ci --rcfile pylintrc --exit-zero --output-format=pylint_gitlab.GitlabCodeClimateReporter AK* > codeclimate.json
......
......@@ -6,7 +6,7 @@ from django.utils.timezone import now
from AKDashboard.models import DashboardButton
from AKModel.models import Event, AK, AKCategory
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
class DashboardTests(TestCase):
......
......@@ -151,9 +151,12 @@ class Availability(models.Model):
if not other.overlaps(self, strict=False):
raise Exception('Only overlapping Availabilities can be merged.')
return Availability(
avail = Availability(
start=min(self.start, other.start), end=max(self.end, other.end)
)
if self.event == other.event:
avail.event = self.event
return avail
def __or__(self, other: 'Availability') -> 'Availability':
"""Performs the merge operation: ``availability1 | availability2``"""
......@@ -168,9 +171,12 @@ class Availability(models.Model):
if not other.overlaps(self, False):
raise Exception('Only overlapping Availabilities can be intersected.')
return Availability(
avail = Availability(
start=max(self.start, other.start), end=min(self.end, other.end)
)
if self.event == other.event:
avail.event = self.event
return avail
def __and__(self, other: 'Availability') -> 'Availability':
"""Performs the intersect operation: ``availability1 &
......@@ -247,7 +253,14 @@ class Availability(models.Model):
f'{self.end.astimezone(self.event.timezone).strftime("%a %H:%M")}')
@classmethod
def with_event_length(cls, event, person=None, room=None, ak=None, ak_category=None):
def with_event_length(
cls,
event: Event,
person: AKOwner | None = None,
room: Room | None = None,
ak: AK | None = None,
ak_category: AKCategory | None = None,
) -> "Availability":
"""
Create an availability covering exactly the time between event start and event end.
Can e.g., be used to create default availabilities.
......@@ -267,6 +280,30 @@ class Availability(models.Model):
return Availability(start=timeframe_start, end=timeframe_end, event=event, person=person,
room=room, ak=ak, ak_category=ak_category)
def is_covered(self, availabilities: List['Availability']):
"""Check if list of availibilities cover this object.
:param availabilities: availabilities to check.
:return: whether the availabilities cover full event.
:rtype: bool
"""
avail_union = Availability.union(availabilities)
return any(avail.contains(self) for avail in avail_union)
@classmethod
def is_event_covered(cls, event: Event, availabilities: List['Availability']) -> bool:
"""Check if list of availibilities cover whole event.
:param event: event to check.
:param availabilities: availabilities to check.
:return: whether the availabilities cover full event.
:rtype: bool
"""
# NOTE: Cannot use `Availability.with_event_length` as its end is the
# event end + 1 day
full_event = Availability(event=event, start=event.start, end=event.end)
return full_event.is_covered(availabilities)
class Meta:
verbose_name = _('Availability')
verbose_name_plural = _('Availabilities')
......
......@@ -93,7 +93,7 @@
"model": "AKModel.akcategory",
"pk": 1,
"fields": {
"name": "Spa",
"name": "Spaß",
"color": "275246",
"description": "",
"present_by_default": true,
......@@ -115,7 +115,7 @@
"model": "AKModel.akcategory",
"pk": 3,
"fields": {
"name": "Spa/Kultur",
"name": "Spaß/Kultur",
"color": "333333",
"description": "",
"present_by_default": true,
......@@ -436,6 +436,62 @@
]
}
},
{
"model": "AKModel.ak",
"pk": 4,
"fields": {
"name": "Test AK fixed slots",
"short_name": "testfixed",
"description": "",
"link": "",
"protocol_link": "",
"category": 4,
"track": null,
"reso": false,
"present": true,
"notes": "",
"interest": -1,
"interest_counter": 0,
"include_in_export": false,
"event": 2,
"owners": [
1
],
"requirements": [
3
],
"conflicts": [],
"prerequisites": []
}
},
{
"model": "AKModel.ak",
"pk": 5,
"fields": {
"name": "Test AK Ernst",
"short_name": "testernst",
"description": "",
"link": "",
"protocol_link": "",
"category": 2,
"track": null,
"reso": false,
"present": true,
"notes": "",
"interest": -1,
"interest_counter": 0,
"include_in_export": false,
"event": 1,
"owners": [
3
],
"requirements": [
2
],
"conflicts": [],
"prerequisites": []
}
},
{
"model": "AKModel.room",
"pk": 1,
......@@ -460,6 +516,19 @@
"properties": []
}
},
{
"model": "AKModel.room",
"pk": 3,
"fields": {
"name": "BBB Session 1",
"location": "",
"capacity": -1,
"event": 1,
"properties": [
2
]
}
},
{
"model": "AKModel.akslot",
"pk": 1,
......@@ -525,6 +594,58 @@
"updated": "2022-12-02T12:23:11.856Z"
}
},
{
"model": "AKModel.akslot",
"pk": 6,
"fields": {
"ak": 4,
"room": null,
"start": "2020-11-08T18:30:00Z",
"duration": "2.00",
"fixed": true,
"event": 2,
"updated": "2022-12-02T12:23:11.856Z"
}
},
{
"model": "AKModel.akslot",
"pk": 7,
"fields": {
"ak": 4,
"room": 2,
"start": null,
"duration": "2.00",
"fixed": true,
"event": 2,
"updated": "2022-12-02T12:23:11.856Z"
}
},
{
"model": "AKModel.akslot",
"pk": 8,
"fields": {
"ak": 4,
"room": 2,
"start": "2020-11-07T16:00:00Z",
"duration": "2.00",
"fixed": true,
"event": 2,
"updated": "2022-12-02T12:23:11.856Z"
}
},
{
"model": "AKModel.akslot",
"pk": 9,
"fields": {
"ak": 5,
"room": null,
"start": null,
"duration": "2.00",
"fixed": false,
"event": 1,
"updated": "2022-12-02T12:23:11.856Z"
}
},
{
"model": "AKModel.constraintviolation",
"pk": 1,
......@@ -668,5 +789,71 @@
"start": "2020-11-07T18:30:00Z",
"end": "2020-11-07T21:30:00Z"
}
},
{
"model": "AKModel.availability",
"pk": 7,
"fields": {
"event": 1,
"person": null,
"room": null,
"ak": 5,
"ak_category": null,
"start": "2020-10-01T17:41:22Z",
"end": "2020-10-04T17:41:30Z"
}
},
{
"model": "AKModel.availability",
"pk": 8,
"fields": {
"event": 1,
"person": null,
"room": 3,
"ak": null,
"ak_category": null,
"start": "2020-10-01T17:41:22Z",
"end": "2020-10-04T17:41:30Z"
}
},
{
"model": "AKModel.defaultslot",
"pk": 1,
"fields": {
"event": 2,
"start": "2020-11-07T08:00:00Z",
"end": "2020-11-07T12:00:00Z",
"primary_categories": [5]
}
},
{
"model": "AKModel.defaultslot",
"pk": 2,
"fields": {
"event": 2,
"start": "2020-11-07T14:00:00Z",
"end": "2020-11-07T17:00:00Z",
"primary_categories": [4]
}
},
{
"model": "AKModel.defaultslot",
"pk": 3,
"fields": {
"event": 2,
"start": "2020-11-08T08:00:00Z",
"end": "2020-11-08T19:00:00Z",
"primary_categories": [4, 5]
}
},
{
"model": "AKModel.defaultslot",
"pk": 4,
"fields": {
"event": 2,
"start": "2020-11-09T17:00:00Z",
"end": "2020-11-10T01:00:00Z",
"primary_categories": [4, 5, 3]
}
}
]
......@@ -272,3 +272,13 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm):
# Filter possible values for m2m when event is specified
if hasattr(self.instance, "event") and self.instance.event is not None:
self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event)
class JSONScheduleImportForm(AdminIntermediateForm):
"""Form to import an AK schedule from a json file."""
json_data = forms.CharField(
required=True,
widget=forms.Textarea,
label=_("JSON data"),
help_text=_("JSON data from the scheduling solver"),
)
import decimal
import itertools
from datetime import timedelta
import json
import math
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Iterable, Generator
from django.db import models
from django.db import models, transaction
from django.apps import apps
from django.db.models import Count
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.datetime_safe import datetime
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from simple_history.models import HistoricalRecords
from timezone_field import TimeZoneField
@dataclass
class OptimizerTimeslot:
"""Class describing a discrete timeslot. Used to interface with an optimizer."""
avail: "Availability"
"""The availability object corresponding to this timeslot."""
idx: int
"""The unique index of this optimizer timeslot."""
constraints: set[str]
"""The set of time constraints fulfilled by this object."""
def merge(self, other: "OptimizerTimeslot") -> "OptimizerTimeslot":
"""Merge with other OptimizerTimeslot.
Creates a new OptimizerTimeslot object.
Its availability is constructed by merging the availabilities of self and other,
its constraints by taking the union of both constraint sets.
As an index, the index of self is used.
"""
avail = self.avail.merge_with(other.avail)
constraints = self.constraints.union(other.constraints)
return OptimizerTimeslot(
avail=avail, idx=self.idx, constraints=constraints
)
def __repr__(self) -> str:
return f"({self.avail.simplified}, {self.idx}, {self.constraints})"
TimeslotBlock = list[OptimizerTimeslot]
def merge_blocks(
blocks: Iterable[TimeslotBlock]
) -> Iterable[TimeslotBlock]:
"""Merge iterable of blocks together.
The timeslots of all blocks are grouped into maximal blocks.
Timeslots with the same start and end are identified with each other
and merged (cf `OptimizerTimeslot.merge`).
Throws a ValueError if any timeslots are overlapping but do not
share the same start and end, i.e. partial overlap is not allowed.
:param blocks: iterable of blocks to merge.
:return: iterable of merged blocks.
:rtype: iterable over lists of OptimizerTimeslot objects
"""
if not blocks:
return []
# flatten timeslot iterables to single chain
timeslot_chain = itertools.chain.from_iterable(blocks)
# sort timeslots according to start
timeslots = sorted(
timeslot_chain,
key=lambda slot: slot.avail.start
)
if not timeslots:
return []
all_blocks = []
current_block = [timeslots[0]]
timeslots = timeslots[1:]
for slot in timeslots:
if current_block and slot.avail.overlaps(current_block[-1].avail, strict=True):
if (
slot.avail.start == current_block[-1].avail.start
and slot.avail.end == current_block[-1].avail.end
):
# the same timeslot -> merge
current_block[-1] = current_block[-1].merge(slot)
else:
# partial overlap of interiors -> not supported
# TODO: Show comprehensive message in production
raise ValueError(
"Partially overlapping timeslots are not supported!"
f" ({current_block[-1].avail.simplified}, {slot.avail.simplified})"
)
elif not current_block or slot.avail.overlaps(current_block[-1].avail, strict=False):
# only endpoints in intersection -> same block
current_block.append(slot)
else:
# no overlap at all -> new block
all_blocks.append(current_block)
current_block = [slot]
if current_block:
all_blocks.append(current_block)
return all_blocks
class Event(models.Model):
"""
An event supplies the frame for all Aks.
......@@ -162,6 +262,221 @@ class Event(models.Model):
.filter(availabilities__count=0, owners__count__gt=0)
)
def _generate_slots_from_block(
self,
start: datetime,
end: datetime,
slot_duration: timedelta,
slot_index: int = 0,
constraints: set[str] | None = None,
) -> Generator[TimeslotBlock, None, int]:
"""Discretize a time range into timeslots.
Uses a uniform discretization into discrete slots of length `slot_duration`,
starting at `start`. No incomplete timeslots are generated, i.e.
if (`end` - `start`) is not a whole number multiple of `slot_duration`
then the last incomplete timeslot is dropped.
:param start: Start of the time range.
:param end: Start of the time range.
:param slot_duration: Duration of a single timeslot in the discretization.
:param slot_index: index of the first timeslot. Defaults to 0.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of OptimizerTimeslot
:return: The first slot index after the yielded blocks, i.e.
`slot_index` + total # generated timeslots
:rtype: int
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
current_slot_start = start
previous_slot_start: datetime | None = None
if constraints is None:
constraints = set()
current_block = []
room_availabilities = list({
availability
for room in Room.objects.filter(event=self)
for availability in room.availabilities.all()
})
while current_slot_start + slot_duration <= end:
slot = Availability(
event=self,
start=current_slot_start,
end=current_slot_start + slot_duration,
)
if any((availability.contains(slot) for availability in room_availabilities)):
# no gap in a block
if (
previous_slot_start is not None
and previous_slot_start + slot_duration < current_slot_start
):
yield current_block
current_block = []
current_block.append(
OptimizerTimeslot(avail=slot, idx=slot_index, constraints=constraints)
)
previous_slot_start = current_slot_start
slot_index += 1
current_slot_start += slot_duration
if current_block:
yield current_block
return slot_index
def uniform_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Uniformly discretize the entire event into blocks of timeslots.
Discretizes entire event uniformly. May not necessarily result in a single block
as slots with no room availability are dropped.
:param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of OptimizerTimeslot
"""
all_category_constraints = AKCategory.create_category_constraints(
AKCategory.objects.filter(event=self).all()
)
yield from self._generate_slots_from_block(
start=self.start,
end=self.end,
slot_duration=timedelta(hours=1.0 / slots_in_an_hour),
constraints=all_category_constraints,
)
def default_time_slots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
"""Discretize all default slots into blocks of timeslots.
In the discretization each default slot corresponds to one block.
:param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of TimeslotBlock
"""
slot_duration = timedelta(hours=1.0 / slots_in_an_hour)
slot_index = 0
for block_slot in DefaultSlot.objects.filter(event=self).order_by("start", "end"):
category_constraints = AKCategory.create_category_constraints(
block_slot.primary_categories.all()
)
slot_index = yield from self._generate_slots_from_block(
start=block_slot.start,
end=block_slot.end,
slot_duration=slot_duration,
slot_index=slot_index,
constraints=category_constraints,
)
def discretize_timeslots(self, *, slots_in_an_hour: float = 1.0) -> Iterable[TimeslotBlock]:
""""Choose discretization scheme.
Uses default_time_slots if the event has any DefaultSlot, otherwise uniform_time_slots.
:param slots_in_an_hour: The percentage of an hour covered by a single slot.
Determines the discretization granularity.
:yield: Block of optimizer timeslots as the discretization result.
:ytype: list of TimeslotBlock
"""
if DefaultSlot.objects.filter(event=self).exists():
# discretize default slots if they exists
yield from merge_blocks(self.default_time_slots(slots_in_an_hour=slots_in_an_hour))
else:
yield from self.uniform_time_slots(slots_in_an_hour=slots_in_an_hour)
@transaction.atomic
def schedule_from_json(self, schedule: str) -> int:
"""Load AK schedule from a json string.
:param schedule: A string that can be decoded to json, describing
the AK schedule. The json data is assumed to be constructed
following the output specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
"""
schedule = json.loads(schedule)
slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"]
timeslot_dict = {
timeslot.idx: timeslot
for block in self.discretize_timeslots(slots_in_an_hour=slots_in_an_hour)
for timeslot in block
}
slots_updated = 0
for scheduled_slot in schedule["scheduled_aks"]:
scheduled_slot["timeslot_ids"] = list(map(int, scheduled_slot["timeslot_ids"]))
slot = AKSlot.objects.get(id=int(scheduled_slot["ak_id"]))
if not scheduled_slot["timeslot_ids"]:
raise ValueError(
_("AK {ak_name} is not assigned any timeslot by the solver").format(ak_name=slot.ak.name)
)
start_timeslot = timeslot_dict[min(scheduled_slot["timeslot_ids"])].avail
end_timeslot = timeslot_dict[max(scheduled_slot["timeslot_ids"])].avail
solver_duration = (end_timeslot.end - start_timeslot.start).total_seconds() / 3600.0
if solver_duration + 2e-4 < slot.duration:
raise ValueError(
_(
"Duration of AK {ak_name} assigned by solver ({solver_duration} hours) "
"is less than the duration required by the slot ({slot_duration} hours)"
).format(
ak_name=slot.ak.name,
solver_duration=solver_duration,
slot_duration=slot.duration,
)
)
if slot.fixed:
solver_room = Room.objects.get(id=int(scheduled_slot["room_id"]))
if slot.room != solver_room:
raise ValueError(
_(
"Fixed AK {ak_name} assigned by solver to room {solver_room} "
"is fixed to room {slot_room}"
).format(
ak_name=slot.ak.name,
solver_room=solver_room.name,
slot_room=slot.room.name,
)
)
if slot.start != start_timeslot.start:
raise ValueError(
_(
"Fixed AK {ak_name} assigned by solver to start at {solver_start} "
"is fixed to start at {slot_start}"
).format(
ak_name=slot.ak.name,
solver_start=start_timeslot.start,
slot_start=slot.start,
)
)
else:
slot.room = Room.objects.get(id=int(scheduled_slot["room_id"]))
slot.start = start_timeslot.start
slot.save()
slots_updated += 1
return slots_updated
class AKOwner(models.Model):
""" An AKOwner describes the person organizing/holding an AK.
......@@ -260,6 +575,20 @@ class AKCategory(models.Model):
def __str__(self):
return self.name
@staticmethod
def create_category_constraints(categories: Iterable["AKCategory"]) -> set[str]:
"""Create a set of constraint strings from an AKCategory iterable.
:param categories: The iterable of categories to derive the constraint strings from.
:return: A set of category constraint strings, i.e. strings of the form
'availability-cat-<cat.name>'.
:rtype: set of strings.
"""
return {
f"availability-cat-{cat.name}"
for cat in categories
}
class AKTrack(models.Model):
""" An AKTrack describes a set of semantically related AKs.
......@@ -379,7 +708,7 @@ class AK(models.Model):
availabilities = ', \n'.join(f'{a.simplified}' for a in Availability.objects.select_related('event')
.filter(ak=self))
detail_string = f"""{self.name}{" (R)" if self.reso else ""}:
{self.owners_list}
{_('Interest')}: {self.interest}"""
......@@ -513,6 +842,43 @@ class Room(models.Model):
def __str__(self):
return self.title
def as_json(self) -> str:
"""Return a json string representation of this room object.
:return: The json string representation is constructed
following the input specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
:rtype: str
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
# check if room is available for the whole event
# -> no time constraint needs to be introduced
if Availability.is_event_covered(self.event, self.availabilities.all()):
time_constraints = []
else:
time_constraints = [f"availability-room-{self.pk}"]
data = {
"id": str(self.pk),
"info": {
"name": self.name,
},
"capacity": self.capacity,
"fulfilled_room_constraints": [constraint.name
for constraint in self.properties.all()],
"time_constraints": time_constraints
}
data["fulfilled_room_constraints"].append(f"fixed-room-{self.pk}")
if not any(constr.startswith("proxy") for constr in data["fulfilled_room_constraints"]):
data["fulfilled_room_constraints"].append("no-proxy")
return json.dumps(data)
class AKSlot(models.Model):
""" An AK Mapping matches an AK to a room during a certain time.
......@@ -608,6 +974,78 @@ class AKSlot(models.Model):
self.duration = min(self.duration, event_duration_hours)
super().save(force_insert, force_update, using, update_fields)
def as_json(self) -> str:
"""Return a json string representation of the AK object of this slot.
:return: The json string representation is constructed
following the input specification of the KoMa conference optimizer, cf.
https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format
:rtype: str
"""
# local import to prevent cyclic import
# pylint: disable=import-outside-toplevel
from AKModel.availability.models import Availability
# check if ak resp. owner is available for the whole event
# -> no time constraint needs to be introduced
if self.fixed and self.start is not None:
ak_time_constraints = [f"fixed-akslot-{self.id}"]
elif not Availability.is_event_covered(self.event, self.ak.availabilities.all()):
ak_time_constraints = [f"availability-ak-{self.ak.pk}"]
else:
ak_time_constraints = []
def _owner_time_constraints(owner: AKOwner):
if Availability.is_event_covered(self.event, owner.availabilities.all()):
return []
return [f"availability-person-{owner.pk}"]
conflict_slots = AKSlot.objects.filter(ak__in=self.ak.conflicts.all())
dependency_slots = AKSlot.objects.filter(ak__in=self.ak.prerequisites.all())
other_ak_slots = AKSlot.objects.filter(ak=self.ak).exclude(pk=self.pk)
ceil_offet_eps = decimal.Decimal(1e-4)
# self.slots_in_an_hour is set in AKJSONExportView
data = {
"id": str(self.pk),
"duration": math.ceil(self.duration * self.slots_in_an_hour - ceil_offet_eps),
"properties": {
"conflicts":
[str(conflict.pk) for conflict in conflict_slots.all()]
+ [str(second_slot.pk) for second_slot in other_ak_slots.all()],
"dependencies": [str(dep.pk) for dep in dependency_slots.all()],
},
"room_constraints": [constraint.name
for constraint in self.ak.requirements.all()],
"time_constraints": ["resolution"] if self.ak.reso else [],
"info": {
"name": self.ak.name,
"head": ", ".join([str(owner)
for owner in self.ak.owners.all()]),
"description": self.ak.description,
"reso": self.ak.reso,
"duration_in_hours": float(self.duration),
"django_ak_id": str(self.ak.pk),
},
}
data["time_constraints"].extend(ak_time_constraints)
for owner in self.ak.owners.all():
data["time_constraints"].extend(_owner_time_constraints(owner))
if self.ak.category:
category_constraints = AKCategory.create_category_constraints([self.ak.category])
data["time_constraints"].extend(category_constraints)
if self.fixed and self.room is not None:
data["room_constraints"].append(f"fixed-room-{self.room.pk}")
if not any(constr.startswith("proxy") for constr in data["room_constraints"]):
data["room_constraints"].append("no-proxy")
return json.dumps(data)
class AKOrgaMessage(models.Model):
"""
......
{% extends "admin/base_site.html" %}
{% load tz %}
{% block content %}
<pre>
{"aks": [
{% for slot in slots %}{{ slot.as_json }}{% if not forloop.last %},
{% endif %}{% endfor %}
],
"rooms": [
{% for room in rooms %}{{ room.as_json }}{% if not forloop.last %},
{% endif %}{% endfor %}
],
"participants": {{ participants }},
"timeslots": {{ timeslots }},
"info": {{ info_dict }}
}
</pre>
{% endblock %}
This diff is collapsed.
......@@ -7,8 +7,19 @@ from django.contrib.messages.storage.base import Message
from django.test import TestCase
from django.urls import reverse_lazy, reverse
from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, Room, AKSlot, AKOrgaMessage, \
ConstraintViolation, DefaultSlot
from AKModel.models import (
Event,
AKOwner,
AKCategory,
AKTrack,
AKRequirement,
AK,
Room,
AKSlot,
AKOrgaMessage,
ConstraintViolation,
DefaultSlot,
)
class BasicViewTests:
......@@ -29,9 +40,10 @@ class BasicViewTests:
since the test framework does not understand the concept of abstract test definitions and would handle this class
as real test case otherwise, distorting the test results.
"""
# pylint: disable=no-member
VIEWS = []
APP_NAME = ''
APP_NAME = ""
VIEWS_STAFF_ONLY = []
EDIT_TESTCASES = []
......@@ -41,16 +53,26 @@ class BasicViewTests:
"""
user_model = get_user_model()
self.staff_user = user_model.objects.create(
username='Test Staff User', email='teststaff@example.com', password='staffpw',
is_staff=True, is_active=True
username="Test Staff User",
email="teststaff@example.com",
password="staffpw",
is_staff=True,
is_active=True,
)
self.admin_user = user_model.objects.create(
username='Test Admin User', email='testadmin@example.com', password='adminpw',
is_staff=True, is_superuser=True, is_active=True
username="Test Admin User",
email="testadmin@example.com",
password="adminpw",
is_staff=True,
is_superuser=True,
is_active=True,
)
self.deactivated_user = user_model.objects.create(
username='Test Deactivated User', email='testdeactivated@example.com', password='deactivatedpw',
is_staff=True, is_active=False
username="Test Deactivated User",
email="testdeactivated@example.com",
password="deactivatedpw",
is_staff=True,
is_active=False,
)
def _name_and_url(self, view_name):
......@@ -62,7 +84,9 @@ class BasicViewTests:
:return: full view name with prefix if applicable, url of the view
:rtype: str, str
"""
view_name_with_prefix = f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
view_name_with_prefix = (
f"{self.APP_NAME}:{view_name[0]}" if self.APP_NAME != "" else view_name[0]
)
url = reverse(view_name_with_prefix, kwargs=view_name[1])
return view_name_with_prefix, url
......@@ -74,7 +98,7 @@ class BasicViewTests:
: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))
messages: List[Message] = list(get_messages(response.wsgi_request))
msg_count = "No message shown to user"
msg_content = "Wrong message, expected '{expected_message}'"
......@@ -95,10 +119,16 @@ class BasicViewTests:
view_name_with_prefix, url = self._name_and_url(view_name)
try:
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{view_name_with_prefix} ({url}) broken")
except Exception: # pylint: disable=broad-exception-caught
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}")
self.assertEqual(
response.status_code,
200,
msg=f"{view_name_with_prefix} ({url}) broken",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
def test_access_control_staff_only(self):
"""
......@@ -107,11 +137,16 @@ class BasicViewTests:
# Not logged in? Views should not be visible
self.client.logout()
for view_name_info in self.VIEWS_STAFF_ONLY:
expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2]
expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url)
self.assertEqual(response.status_code, expected_response_code,
msg=f"{view_name_with_prefix} ({url}) accessible by non-staff")
self.assertEqual(
response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) accessible by non-staff",
)
# Logged in? Views should be visible
self.client.force_login(self.staff_user)
......@@ -119,20 +154,30 @@ class BasicViewTests:
view_name_with_prefix, url = self._name_and_url(view_name_info)
try:
response = self.client.get(url)
self.assertEqual(response.status_code, 200,
msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)")
self.assertEqual(
response.status_code,
200,
msg=f"{view_name_with_prefix} ({url}) should be accessible for staff (but isn't)",
)
except Exception: # pylint: disable=broad-exception-caught
self.fail(f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}")
self.fail(
f"An error occurred during rendering of {view_name_with_prefix} ({url}):"
f"\n\n{traceback.format_exc()}"
)
# Disabled user? Views should not be visible
self.client.force_login(self.deactivated_user)
for view_name_info in self.VIEWS_STAFF_ONLY:
expected_response_code = 302 if len(view_name_info) == 2 else view_name_info[2]
expected_response_code = (
302 if len(view_name_info) == 2 else view_name_info[2]
)
view_name_with_prefix, url = self._name_and_url(view_name_info)
response = self.client.get(url)
self.assertEqual(response.status_code, expected_response_code,
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user")
self.assertEqual(
response.status_code,
expected_response_code,
msg=f"{view_name_with_prefix} ({url}) still accessible for deactivated user",
)
def _to_sendable_value(self, val):
"""
......@@ -182,16 +227,26 @@ class BasicViewTests:
self.client.logout()
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"{name}: Could not load edit form via GET ({url})")
self.assertEqual(
response.status_code,
200,
msg=f"{name}: Could not load edit form via GET ({url})",
)
form = response.context[form_name]
data = {k:self._to_sendable_value(v) for k,v in form.initial.items()}
data = {k: self._to_sendable_value(v) for k, v in form.initial.items()}
response = self.client.post(url, data=data)
if expected_code == 200:
self.assertEqual(response.status_code, 200, msg=f"{name}: Did not return 200 ({url}")
self.assertEqual(
response.status_code, 200, msg=f"{name}: Did not return 200 ({url}"
)
elif expected_code == 302:
self.assertRedirects(response, target_url, msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}")
self.assertRedirects(
response,
target_url,
msg_prefix=f"{name}: Did not redirect ({url} -> {target_url}",
)
if expected_message != "":
self._assert_message(response, expected_message, msg_prefix=f"{name}")
......@@ -200,30 +255,44 @@ class ModelViewTests(BasicViewTests, TestCase):
"""
Basic view test cases for views from AKModel plus some custom tests
"""
fixtures = ['model.json']
fixtures = ["model.json"]
ADMIN_MODELS = [
(Event, 'event'), (AKOwner, 'akowner'), (AKCategory, 'akcategory'), (AKTrack, 'aktrack'),
(AKRequirement, 'akrequirement'), (AK, 'ak'), (Room, 'room'), (AKSlot, 'akslot'),
(AKOrgaMessage, 'akorgamessage'), (ConstraintViolation, 'constraintviolation'),
(DefaultSlot, 'defaultslot')
(Event, "event"),
(AKOwner, "akowner"),
(AKCategory, "akcategory"),
(AKTrack, "aktrack"),
(AKRequirement, "akrequirement"),
(AK, "ak"),
(Room, "room"),
(AKSlot, "akslot"),
(AKOrgaMessage, "akorgamessage"),
(ConstraintViolation, "constraintviolation"),
(DefaultSlot, "defaultslot"),
]
VIEWS_STAFF_ONLY = [
('admin:index', {}),
('admin:event_status', {'event_slug': 'kif42'}),
('admin:event_requirement_overview', {'event_slug': 'kif42'}),
('admin:ak_csv_export', {'event_slug': 'kif42'}),
('admin:ak_wiki_export', {'slug': 'kif42'}),
('admin:ak_delete_orga_messages', {'event_slug': 'kif42'}),
('admin:ak_slide_export', {'event_slug': 'kif42'}),
('admin:default-slots-editor', {'event_slug': 'kif42'}),
('admin:room-import', {'event_slug': 'kif42'}),
('admin:new_event_wizard_start', {}),
("admin:index", {}),
("admin:event_status", {"event_slug": "kif42"}),
("admin:event_requirement_overview", {"event_slug": "kif42"}),
("admin:ak_csv_export", {"event_slug": "kif42"}),
("admin:ak_json_export", {"event_slug": "kif42"}),
("admin:ak_wiki_export", {"slug": "kif42"}),
("admin:ak_schedule_json_import", {"event_slug": "kif42"}),
("admin:ak_delete_orga_messages", {"event_slug": "kif42"}),
("admin:ak_slide_export", {"event_slug": "kif42"}),
("admin:default-slots-editor", {"event_slug": "kif42"}),
("admin:room-import", {"event_slug": "kif42"}),
("admin:new_event_wizard_start", {}),
]
EDIT_TESTCASES = [
{'view': 'admin:default-slots-editor', 'kwargs': {'event_slug': 'kif42'}, "admin": True},
{
"view": "admin:default-slots-editor",
"kwargs": {"event_slug": "kif42"},
"admin": True,
},
]
def test_admin(self):
......@@ -234,24 +303,32 @@ class ModelViewTests(BasicViewTests, TestCase):
for model in self.ADMIN_MODELS:
# Special treatment for a subset of views (where we exchanged default functionality, e.g., create views)
if model[1] == "event":
_, url = self._name_and_url(('admin:new_event_wizard_start', {}))
_, url = self._name_and_url(("admin:new_event_wizard_start", {}))
elif model[1] == "room":
_, url = self._name_and_url(('admin:room-new', {}))
_, url = self._name_and_url(("admin:room-new", {}))
# Otherwise, just call the creation form view
else:
_, url = self._name_and_url((f'admin:AKModel_{model[1]}_add', {}))
_, url = self._name_and_url((f"admin:AKModel_{model[1]}_add", {}))
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Add form for model {model[1]} ({url}) broken")
self.assertEqual(
response.status_code,
200,
msg=f"Add form for model {model[1]} ({url}) broken",
)
for model in self.ADMIN_MODELS:
# Test the update view using the first existing instance of each model
m = model[0].objects.first()
if m is not None:
_, url = self._name_and_url(
(f'admin:AKModel_{model[1]}_change', {'object_id': m.pk})
(f"admin:AKModel_{model[1]}_change", {"object_id": m.pk})
)
response = self.client.get(url)
self.assertEqual(response.status_code, 200, msg=f"Edit form for model {model[1]} ({url}) broken")
self.assertEqual(
response.status_code,
200,
msg=f"Edit form for model {model[1]} ({url}) broken",
)
def test_wiki_export(self):
"""
......@@ -260,17 +337,27 @@ class ModelViewTests(BasicViewTests, TestCase):
"""
self.client.force_login(self.admin_user)
export_url = reverse_lazy("admin:ak_wiki_export", kwargs={'slug': 'kif42'})
export_url = reverse_lazy("admin:ak_wiki_export", kwargs={"slug": "kif42"})
response = self.client.get(export_url)
self.assertEqual(response.status_code, 200, "Export not working at all")
export_count = 0
for _, aks in response.context["categories_with_aks"]:
for ak in aks:
self.assertEqual(ak.include_in_export, True,
f"AK with export flag set to False (pk={ak.pk}) included in export")
self.assertNotEqual(ak.pk, 1, "AK known to be excluded from export (PK 1) included in export")
self.assertEqual(
ak.include_in_export,
True,
f"AK with export flag set to False (pk={ak.pk}) included in export",
)
self.assertNotEqual(
ak.pk,
1,
"AK known to be excluded from export (PK 1) included in export",
)
export_count += 1
self.assertEqual(export_count, AK.objects.filter(event_id=2, include_in_export=True).count(),
"Wiki export contained the wrong number of AKs")
self.assertEqual(
export_count,
AK.objects.filter(event_id=2, include_in_export=True).count(),
"Wiki export contained the wrong number of AKs",
)
......@@ -5,8 +5,9 @@ from rest_framework.routers import DefaultRouter
import AKModel.views.api
from AKModel.views.manage import ExportSlidesView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, \
AKsByUserView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKWikiExportView, AKMessageDeleteView
AKsByUserView, AKScheduleJSONImportView
from AKModel.views.ak import AKRequirementOverview, AKCSVExportView, AKJSONExportView, AKWikiExportView, \
AKMessageDeleteView
from AKModel.views.event_wizard import NewEventWizardStartView, NewEventWizardPrepareImportView, \
NewEventWizardImportView, NewEventWizardActivateView, NewEventWizardFinishView, NewEventWizardSettingsView
from AKModel.views.room import RoomBatchCreationView
......@@ -96,6 +97,10 @@ def get_admin_urls_event(admin_site):
name="aks_by_owner"),
path('<slug:event_slug>/ak-csv-export/', admin_site.admin_view(AKCSVExportView.as_view()),
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()),
name="ak_wiki_export"),
path('<slug:event_slug>/delete-orga-messages/', admin_site.admin_view(AKMessageDeleteView.as_view()),
......
import json
from typing import List
from django.contrib import messages
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from django.views.generic import ListView, DetailView
from AKModel.availability.models import Availability
from AKModel.metaviews.admin import AdminViewMixin, FilterByEventSlugMixin, EventSlugMixin, IntermediateAdminView, \
IntermediateAdminActionView
from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK
from AKModel.models import AKRequirement, AKSlot, Event, AKOrgaMessage, AK, Room, AKOwner
class AKRequirementOverview(AdminViewMixin, FilterByEventSlugMixin, ListView):
......@@ -37,6 +41,144 @@ class AKCSVExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
return super().get_queryset().order_by("ak__track")
class AKJSONExportView(AdminViewMixin, FilterByEventSlugMixin, ListView):
"""
View: Export all AK slots of this event in JSON format ordered by tracks
"""
template_name = "admin/AKModel/ak_json_export.html"
model = AKSlot
context_object_name = "slots"
title = _("AK JSON Export")
def _test_event_not_covered(self, availabilities: List[Availability]) -> bool:
"""Test if event is not covered by availabilities."""
return not Availability.is_event_covered(self.event, availabilities)
def _test_akslot_fixed_in_timeslot(self, ak_slot: AKSlot, timeslot: Availability) -> bool:
"""Test if an AKSlot is fixed to overlap a timeslot slot."""
if not ak_slot.fixed or ak_slot.start is None:
return False
fixed_avail = Availability(event=self.event, start=ak_slot.start, end=ak_slot.end)
return fixed_avail.overlaps(timeslot, strict=True)
def _test_add_constraint(self, slot: Availability, availabilities: List[Availability]) -> bool:
"""Test if object is not available for whole event and may happen during slot."""
return (
self._test_event_not_covered(availabilities) and slot.is_covered(availabilities)
)
def _generate_time_constraints(
self,
avail_label: str,
avail_dict: dict,
timeslot_avail: Availability,
prefix: str = "availability",
) -> list[str]:
return [
f"{prefix}-{avail_label}-{pk}"
for pk, availabilities in avail_dict.items()
if self._test_add_constraint(timeslot_avail, availabilities)
]
def get_queryset(self):
return super().get_queryset().order_by("ak__track")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["participants"] = json.dumps([])
rooms = Room.objects.filter(event=self.event)
context["rooms"] = rooms
# TODO: Configure magic number in event
SLOTS_IN_AN_HOUR = 1
timeslots = {
"info": {"duration": (1.0 / SLOTS_IN_AN_HOUR), },
"blocks": [],
}
for slot in context["slots"]:
slot.slots_in_an_hour = SLOTS_IN_AN_HOUR
ak_availabilities = {
ak.pk: Availability.union(ak.availabilities.all())
for ak in AK.objects.filter(event=self.event).all()
}
room_availabilities = {
room.pk: Availability.union(room.availabilities.all())
for room in rooms
}
person_availabilities = {
person.pk: Availability.union(person.availabilities.all())
for person in AKOwner.objects.filter(event=self.event)
}
blocks = self.event.discretize_timeslots(slots_in_an_hour=SLOTS_IN_AN_HOUR)
for block in blocks:
current_block = []
for timeslot in block:
time_constraints = []
# if reso_deadline is set and timeslot ends before it,
# add fulfilled time constraint 'resolution'
if self.event.reso_deadline is None or timeslot.avail.end < self.event.reso_deadline:
time_constraints.append("resolution")
# add fulfilled time constraints for all AKs that cannot happen during full event
time_constraints.extend(
self._generate_time_constraints("ak", ak_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all persons that are not available for full event
time_constraints.extend(
self._generate_time_constraints("person", person_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all rooms that are not available for full event
time_constraints.extend(
self._generate_time_constraints("room", room_availabilities, timeslot.avail)
)
# add fulfilled time constraints for all AKSlots fixed to happen during timeslot
time_constraints.extend([
f"fixed-akslot-{slot.id}"
for slot in AKSlot.objects.filter(event=self.event, fixed=True)
.exclude(start__isnull=True)
if self._test_akslot_fixed_in_timeslot(slot, timeslot.avail)
])
time_constraints.extend(timeslot.constraints)
current_block.append({
"id": str(timeslot.idx),
"info": {
"start": timeslot.avail.start.astimezone(self.event.timezone).strftime("%Y-%m-%d %H:%M"),
"end": timeslot.avail.end.astimezone(self.event.timezone).strftime("%Y-%m-%d %H:%M"),
},
"fulfilled_time_constraints": time_constraints,
})
timeslots["blocks"].append(current_block)
context["timeslots"] = json.dumps(timeslots)
info_dict = {
"title": self.event.name,
"slug": self.event.slug
}
for attr in ["contact_email", "place"]:
if hasattr(self.event, attr) and getattr(self.event, attr):
info_dict[attr] = getattr(self.event, attr)
context["info_dict"] = json.dumps(info_dict)
return context
class AKWikiExportView(AdminViewMixin, DetailView):
"""
View: Export AKs of this event in wiki syntax
......
......@@ -4,15 +4,17 @@ import os
import tempfile
from itertools import zip_longest
from django.contrib import messages
from django.db.models.functions import Now
from django.shortcuts import redirect
from django.utils.dateparse import parse_datetime
from django.utils.translation import gettext_lazy as _
from django.views.generic import TemplateView, DetailView
from django_tex.core import render_template_with_context, run_tex_in_directory
from django_tex.response import PDFResponse
from AKModel.forms import SlideExportForm, DefaultSlotEditorForm
from AKModel.forms import SlideExportForm, DefaultSlotEditorForm, JSONScheduleImportForm
from AKModel.metaviews.admin import EventSlugMixin, IntermediateAdminView, IntermediateAdminActionView, AdminViewMixin
from AKModel.models import ConstraintViolation, Event, DefaultSlot, AKOwner
......@@ -58,7 +60,7 @@ class ExportSlidesView(EventSlugMixin, IntermediateAdminView):
Create a list of tuples cosisting of an AK and a list of upcoming AKs (list length depending on setting)
"""
next_aks_list = zip_longest(*[ak_list[i + 1:] for i in range(NEXT_AK_LIST_LENGTH)], fillvalue=None)
return [(ak, next_aks) for ak, next_aks in zip_longest(ak_list, next_aks_list, fillvalue=[])]
return list(zip_longest(ak_list, next_aks_list, fillvalue=[]))
# 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)
......@@ -245,3 +247,28 @@ class AKsByUserView(AdminViewMixin, EventSlugMixin, DetailView):
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.
"""
form_class = JSONScheduleImportForm
title = _("AK Schedule JSON Import")
def form_valid(self, form):
try:
number_of_slots_changed = self.event.schedule_from_json(form.data["json_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)
......@@ -133,10 +133,18 @@ class EventAKsWidget(TemplateStatusWidget):
"text": _("Manage ak tracks"),
"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"),
"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"),
"url": reverse_lazy("admin:ak_wiki_export", kwargs={"slug": context["event"].slug}),
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-15 20:03+0200\n"
"POT-Creation-Date: 2024-05-27 01:57+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -38,7 +38,7 @@ msgstr "Veranstaltung"
#: AKPlan/templates/AKPlan/plan_index.html:59
#: AKPlan/templates/AKPlan/plan_room.html:13
#: AKPlan/templates/AKPlan/plan_room.html:59
#: AKPlan/templates/AKPlan/plan_wall.html:65
#: AKPlan/templates/AKPlan/plan_wall.html:67
msgid "Room"
msgstr "Raum"
......@@ -63,12 +63,12 @@ msgid "AK Wall"
msgstr "AK-Wall"
#: AKPlan/templates/AKPlan/plan_index.html:130
#: AKPlan/templates/AKPlan/plan_wall.html:130
#: AKPlan/templates/AKPlan/plan_wall.html:132
msgid "Current AKs"
msgstr "Aktuelle AKs"
#: AKPlan/templates/AKPlan/plan_index.html:137
#: AKPlan/templates/AKPlan/plan_wall.html:135
#: AKPlan/templates/AKPlan/plan_wall.html:137
msgid "Next AKs"
msgstr "Nächste AKs"
......@@ -99,7 +99,7 @@ msgstr "Eigenschaften"
msgid "Track"
msgstr "Track"
#: AKPlan/templates/AKPlan/plan_wall.html:145
#: AKPlan/templates/AKPlan/plan_wall.html:147
msgid "Reload page automatically?"
msgstr "Seite automatisch neu laden?"
......
from django.test import TestCase
from AKModel.tests import BasicViewTests
from AKModel.tests.test_views import BasicViewTests
class PlanViewTests(BasicViewTests, TestCase):
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-16 16:30+0200\n"
"POT-Creation-Date: 2024-05-27 01:57+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -17,10 +17,10 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: AKPlanning/settings.py:148
#: AKPlanning/settings.py:147
msgid "German"
msgstr "Deutsch"
#: AKPlanning/settings.py:149
#: AKPlanning/settings.py:148
msgid "English"
msgstr "Englisch"
......@@ -55,7 +55,9 @@ class EventsView(LoginRequiredMixin, EventSlugMixin, ListView):
model = AKSlot
def get_queryset(self):
return super().get_queryset().select_related('ak').filter(event=self.event, room__isnull=False)
return super().get_queryset().select_related('ak').filter(
event=self.event, room__isnull=False, start__isnull=False
)
def render_to_response(self, context, **response_kwargs):
return JsonResponse(
......
......@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-04-25 00:24+0200\n"
"POT-Creation-Date: 2025-01-22 19:00+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
......@@ -27,7 +27,7 @@ msgstr "Ende"
#: AKScheduling/forms.py:26
msgid "Duration"
msgstr ""
msgstr "Dauer"
#: AKScheduling/forms.py:27
#: AKScheduling/templates/admin/AKScheduling/scheduling.html:171
......@@ -107,6 +107,7 @@ msgid "Event Status"
msgstr "Event-Status"
#: AKScheduling/templates/admin/AKScheduling/constraint_violations.html:113
#: AKScheduling/views.py:48
msgid "Scheduling"
msgstr "Scheduling"
......@@ -239,6 +240,7 @@ msgstr[1] ""
" "
#: AKScheduling/templates/admin/AKScheduling/unscheduled.html:7
#: AKScheduling/views.py:25
msgid "Unscheduled AK Slots"
msgstr "Noch nicht geschedulte AK-Slots"
......@@ -246,10 +248,22 @@ msgstr "Noch nicht geschedulte AK-Slots"
msgid "Count"
msgstr "Anzahl"
#: AKScheduling/views.py:89
msgid "Constraint violations for"
msgstr "Constraintverletzungen für"
#: AKScheduling/views.py:104
msgid "AKs requiring special attention for"
msgstr "AKs die besondere Aufmerksamkeit erfordern für"
#: AKScheduling/views.py:150
msgid "Interest updated"
msgstr "Interesse aktualisiert"
#: AKScheduling/views.py:157
msgid "Enter interest"
msgstr "Interesse eingeben"
#: AKScheduling/views.py:201
msgid "Wishes"
msgstr "Wünsche"
......