Compare commits

..

11 Commits

Author SHA1 Message Date
xAndy d034e39cb8 add setting to supress room change messages
continuous-integration/drone/push Build is passing Details
2025-05-18 01:29:10 +02:00
xAndy 04d85811fe change default required helpers to 1, even in import, so locations can be changed 2025-05-18 01:29:10 +02:00
Luca ce8316a69c fix(fallback): fix order of yielded tuple
continuous-integration/drone/push Build is passing Details
2025-05-18 01:18:39 +02:00
Luca d89d480170 feat: improve fallback shift assignment algorithm
continuous-integration/drone/push Build is passing Details
2025-05-18 01:03:19 +02:00
xAndy f2c00ec8d9 avoid auto registration via link previews
continuous-integration/drone/push Build is passing Details
2025-05-17 23:44:13 +02:00
Luca 38cdbec9fa feat(fallback): assign restricted shifts from their own bucket
continuous-integration/drone/push Build is passing Details
2025-05-17 20:13:00 +02:00
xAndy d7d26c56b0 allow shift registrations end to start
continuous-integration/drone/push Build is passing Details
2025-05-17 20:01:02 +02:00
Luca c5bb532749 feat: implement restricted shifts
continuous-integration/drone/push Build is passing Details
2025-05-17 19:38:22 +02:00
Luca 701caae254 fix: hide 'AStA' tag if 'enable_asta' is False 2025-05-17 19:38:22 +02:00
Luca 797c5f5fc8 docs: add another SMS text 2025-05-17 19:38:22 +02:00
Luca 1509cb6312 chore: add migration for room default_helpers=1 2025-05-17 19:38:22 +02:00
18 changed files with 289 additions and 93 deletions

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2025-05-16 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("app", "0013_room_send_reminders"),
]
operations = [
migrations.AlterField(
model_name="room",
name="required_helpers",
field=models.IntegerField(default=1),
),
]

View File

@ -28,7 +28,43 @@ class Room(models.Model):
return self.shift_set.filter(deleted=False) return self.shift_set.filter(deleted=False)
class ShiftManager(models.Manager):
def with_reg_count(self):
return (
self.get_queryset()
.annotate(
reg_count=Count(
"shiftregistration",
distinct=True,
filter=Q(
shiftregistration__state__in=(
ShiftRegistration.RegState.REGISTERED,
ShiftRegistration.RegState.CHECKED_IN,
)
),
)
)
.select_related("room")
)
class DisplayShiftManager(ShiftManager):
def get_queryset(self):
return (
super()
.get_queryset()
.select_related("event__calendar")
.filter(
Q(event__isnull=True) | Q(event__calendar__restricted=False),
deleted=False,
)
)
class Shift(models.Model): class Shift(models.Model):
all_objects = ShiftManager()
objects = DisplayShiftManager()
room = models.ForeignKey(Room, on_delete=models.RESTRICT) room = models.ForeignKey(Room, on_delete=models.RESTRICT)
start_at = models.DateTimeField(db_index=True) start_at = models.DateTimeField(db_index=True)
duration = models.DurationField() duration = models.DurationField()
@ -38,20 +74,6 @@ class Shift(models.Model):
description = models.TextField(blank=True, default="") description = models.TextField(blank=True, default="")
deleted = models.BooleanField(default=False, db_index=True) deleted = models.BooleanField(default=False, db_index=True)
def with_reg_count():
return Shift.objects.annotate(
reg_count=Count(
"shiftregistration",
distinct=True,
filter=Q(
shiftregistration__state__in=(
ShiftRegistration.RegState.REGISTERED,
ShiftRegistration.RegState.CHECKED_IN,
)
),
)
).select_related("room")
def __str__(self): def __str__(self):
return f"{self.room.name}: {self.start_at}" return f"{self.room.name}: {self.start_at}"
@ -123,10 +145,10 @@ class Helper(models.Model):
) )
| |
# Case 2: End time falls between new shift's start and end # Case 2: End time falls between new shift's start and end
Q(shift_end__gt=shift.start_at, shift_end__lte=new_shift_end) Q(shift_end__gt=shift.start_at, shift_end__lt=new_shift_end)
| |
# Case 3: Completely encompasses the new shift # Case 3: Completely encompasses the new shift
Q(shift__start_at__lte=shift.start_at, shift_end__gte=new_shift_end) Q(shift__start_at__lt=shift.start_at, shift_end__gt=new_shift_end)
) )
.first() .first()
) )

View File

@ -16,7 +16,7 @@ def notify_shift_changed(sender, **kwargs):
if issubclass(sender, Shift): if issubclass(sender, Shift):
instance = kwargs["instance"] instance = kwargs["instance"]
try: try:
prev = Shift.objects.get(pk=instance.id) prev = Shift.all_objects.get(pk=instance.id)
except Shift.DoesNotExist: except Shift.DoesNotExist:
return return
room_unchanged = (prev.room == instance.room) or global_preferences[ room_unchanged = (prev.room == instance.room) or global_preferences[

View File

@ -0,0 +1,11 @@
{% extends "helper_base.html" %}
{% block title %}Registrierung{% endblock %}
{% block content %}
<p>Du solltest automatisch eingeloggt werden. Falls nicht, klicke bitte hier:</p>
<a class="button is-link" href="{% url 'login_confirm' token %}">Login</a>
<script>
window.location.href = "{% url 'login_confirm' token %}";
</script>
{% endblock %}

View File

@ -13,11 +13,13 @@
<div class="notification">Diese Schicht wurde gelöscht.</div> <div class="notification">Diese Schicht wurde gelöscht.</div>
{% endif %} {% endif %}
{% if not can_register and not is_registered %} {% if not can_register and not is_registered %}
{% if has_overlap %} {% if shift.restricted %}
<div class="notification is-warning">Diese Schicht kann nur von Teamis besetzt werden, daher kannst du dich nicht anmelden.</div>
{% elif has_overlap %}
<div class="notification is-warning">Du hast bereits eine überlappende Schicht zu dieser Zeit: <a href="{% url 'shift' overlapping_shift.id %}">{{ overlapping_shift.room.name }} ({{ overlapping_shift.start_at }})</a></div> <div class="notification is-warning">Du hast bereits eine überlappende Schicht zu dieser Zeit: <a href="{% url 'shift' overlapping_shift.id %}">{{ overlapping_shift.room.name }} ({{ overlapping_shift.start_at }})</a></div>
{% else %} {% else %}
<div class="notification">Diese Schicht ist bereits besetzt.</div> <div class="notification">Diese Schicht ist bereits besetzt.</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
<div class="content"> <div class="content">
<p> <p>

View File

@ -44,11 +44,32 @@ class ShiftOverlapTests(TestCase):
) )
self.assertIsNone(self.helper.has_overlapping_shift(shift_before)) self.assertIsNone(self.helper.has_overlapping_shift(shift_before))
def test_back_to_back_shifts(self):
"""Test that a shift starting exactly when another ends is allowed"""
shift_after = Shift.objects.create(
room=self.room,
start_at=self.base_shift.start_at + self.base_shift.duration,
duration=timedelta(hours=1),
required_helpers=1,
)
self.assertIsNone(self.helper.has_overlapping_shift(shift_after))
# Also test the reverse case - registering for a shift that ends exactly when another begins
self.base_registration.delete()
ShiftRegistration.objects.create(
shift=shift_after,
helper=self.helper,
state=ShiftRegistration.RegState.REGISTERED,
)
self.assertIsNone(self.helper.has_overlapping_shift(self.base_shift))
def test_no_overlap_after(self): def test_no_overlap_after(self):
"""Test a shift that starts after the base shift ends""" """Test a shift that starts after the base shift ends"""
shift_after = Shift.objects.create( shift_after = Shift.objects.create(
room=self.room, room=self.room,
start_at=self.base_shift.start_at + timedelta(hours=4), start_at=self.base_shift.start_at
+ self.base_shift.duration
+ timedelta(hours=1),
duration=timedelta(hours=1), duration=timedelta(hours=1),
required_helpers=1, required_helpers=1,
) )

View File

@ -5,6 +5,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("l/<slug:token>", views.login, name="token_login"), path("l/<slug:token>", views.login, name="token_login"),
path("login_confirm/<slug:token>", views.login_confirm, name="login_confirm"),
path("logout", views.logout, name="token_logout"), path("logout", views.logout, name="token_logout"),
path("register", views.register, name="register"), path("register", views.register, name="register"),
path("asta", views.asta, name="asta"), path("asta", views.asta, name="asta"),

View File

@ -8,6 +8,7 @@ from django.core.cache import cache
from django.db import transaction from django.db import transaction
from django.db.models import Count, ExpressionWrapper, F, Q from django.db.models import Count, ExpressionWrapper, F, Q
from django.db.models.fields import DateTimeField from django.db.models.fields import DateTimeField
from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
@ -30,7 +31,7 @@ def index(request):
event_end_at = global_preferences["helper__event_end_at"] event_end_at = global_preferences["helper__event_end_at"]
days = ( days = (
Shift.objects.filter( Shift.objects.filter(
deleted=False, start_at__gte=event_start_at, start_at__lte=event_end_at start_at__gte=event_start_at, start_at__lte=event_end_at
) )
.datetimes("start_at", "day") .datetimes("start_at", "day")
.all() .all()
@ -65,13 +66,12 @@ def index(request):
# dont show shifts starting in <60 minutes? # dont show shifts starting in <60 minutes?
# currently only sorts by date # currently only sorts by date
free_shifts = ( free_shifts = (
Shift.with_reg_count() Shift.objects.with_reg_count()
.filter( .filter(
help_wanted, help_wanted,
start_at__gte=day + timedelta(hours=6), start_at__gte=day + timedelta(hours=6),
start_at__lte=day + timedelta(hours=30), start_at__lte=day + timedelta(hours=30),
start_at__gt=timezone.now(), start_at__gt=timezone.now(),
deleted=False,
) )
.order_by("start_at") .order_by("start_at")
for day in days for day in days
@ -98,7 +98,19 @@ def login(request, token):
"Wir konnten dich nicht in unserer Datenbank finden. Bitte registriere dich neu, auch wenn du letztes Jahr bereits geholfen hast.", "Wir konnten dich nicht in unserer Datenbank finden. Bitte registriere dich neu, auch wenn du letztes Jahr bereits geholfen hast.",
) )
return redirect("register") return redirect("register")
return render(request, "login.html", {"token": token})
def login_confirm(request, token):
try:
tk = LoginToken.objects.get(pk=token)
except LoginToken.DoesNotExist:
messages.add_message(
request,
messages.WARNING,
"Wir konnten dich nicht in unserer Datenbank finden. Bitte registriere dich neu, auch wenn du letztes Jahr bereits geholfen hast.",
)
return redirect("register")
if not tk.helper.number_validated: if not tk.helper.number_validated:
tk.helper.number_validated = True tk.helper.number_validated = True
tk.helper.save() tk.helper.save()
@ -191,7 +203,12 @@ def register(request):
@event_state @event_state
def shift(request, shiftid): def shift(request, shiftid):
shift = get_object_or_404(Shift.with_reg_count(), pk=shiftid) shift = get_object_or_404(
Shift.all_objects.with_reg_count()
.select_related("event__calendar")
.annotate(restricted=Coalesce("event__calendar__restricted", False)),
pk=shiftid,
)
helper = request.helper helper = request.helper
context = { context = {
"enable_asta": global_preferences["helper__enable_asta"], "enable_asta": global_preferences["helper__enable_asta"],
@ -201,15 +218,14 @@ def shift(request, shiftid):
"shift": shift, "shift": shift,
"shift_form": EmptyForm, "shift_form": EmptyForm,
} }
context["can_register"] = (
# this currently ignores date/time
request.session["last_seen_shift"] = shiftid
if (
shift.required_helpers > shift.registration_count() shift.required_helpers > shift.registration_count()
or shift.required_helpers == 0 or shift.required_helpers == 0
and shift.room.required_helpers > shift.registration_count() and shift.room.required_helpers > shift.registration_count()
): ) and not shift.restricted
context["can_register"] = True
# this currently ignores date/time
request.session["last_seen_shift"] = shiftid
if helper: if helper:
context["helper"] = helper context["helper"] = helper

View File

@ -0,0 +1,56 @@
import math
from random import random
from django.db.models import Case, Count, F, Sum, When
from shiftregister.importer.models import Event
class Bucket:
def __init__(self, selector, sub_buckets=[]):
if not isinstance(sub_buckets, list) or not all(
map(lambda b: isinstance(b, Bucket), sub_buckets)
):
raise ValueError("sub_buckets must be a list of instances of class Bucket")
self.selector = selector
self.sub_buckets = sub_buckets
def __iter__(self):
for bucket in self.sub_buckets:
yield bucket.selector, bucket
def has_sub_buckets(self):
return len(self.sub_buckets) > 0
def distribute(total_slots, total_assignments, pool, qs=None):
if not isinstance(pool, Bucket):
raise ValueError("pool must be an instance of class Bucket")
qs = (qs or Event.objects).filter(pool.selector)
for selector, bucket in pool:
bucket_qs = qs.filter(selector).annotate(
real_required_helpers=Case(
When(required_helpers=0, then=F("room__required_helpers")),
default=F("required_helpers"),
)
)
bucket_slots = bucket_qs.aggregate(sum=Sum("real_required_helpers"))["sum"]
extra_chance, bucket_assignments = math.modf(
bucket_slots / total_slots * total_assignments
)
bucket_assignments = int(bucket_assignments)
if extra_chance > random():
bucket_assignments += 1
if bucket.has_sub_buckets():
yield from distribute(bucket_slots, bucket_assignments, bucket, qs)
else:
yield bucket_qs, bucket_assignments
total_slots -= bucket_slots
total_assignments -= bucket_assignments

View File

@ -12,6 +12,8 @@ from django.utils import timezone
from shiftregister.importer.models import * from shiftregister.importer.models import *
from .distribution import Bucket, distribute
def generate_id(): def generate_id():
return int.from_bytes(secrets.token_bytes(3), byteorder="big") return int.from_bytes(secrets.token_bytes(3), byteorder="big")
@ -40,8 +42,38 @@ class TeamMember(models.Model):
return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8") return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8")
def assign_random_shifts(self): def assign_random_shifts(self):
if self.fallback_shifts.count() != 0:
return
needs_fallback = Q(deleted=False, calendar__needs_fallback=True) needs_fallback = Q(deleted=False, calendar__needs_fallback=True)
total_slots = (
Event.objects.filter(needs_fallback)
.annotate(
real_required_helpers=Case(
When(required_helpers=0, then=F("room__required_helpers")),
default=F("required_helpers"),
),
)
.aggregate(sum=Sum("real_required_helpers"))["sum"]
)
number_of_team_members = TeamMember.objects.count()
quota = global_preferences["helper__fallback_quota"]
max_shifts_per_member = total_slots / max(number_of_team_members * quota, 1)
active_team_members = (
TeamMember.objects.filter(~Q(fallback_shifts=None)).count() + 1
)
shifts_per_member = total_slots / active_team_members
extra_chance, total_assignments = math.modf(
min(max_shifts_per_member, shifts_per_member)
)
total_assignments = int(total_assignments)
if extra_chance > random():
total_assignments += 1
current_tz = timezone.get_current_timezone() current_tz = timezone.get_current_timezone()
# create a datetime combining the last date having fallback shifts # create a datetime combining the last date having fallback shifts
# after 20:00 with the time 20:00 in the current timezone # after 20:00 with the time 20:00 in the current timezone
@ -56,47 +88,38 @@ class TeamMember(models.Model):
is_last_night = Q(start_at__gte=last_night) is_last_night = Q(start_at__gte=last_night)
is_night_shift = Q(start_at__hour__gte=21) | Q(start_at__hour__lte=10) is_night_shift = Q(start_at__hour__gte=21) | Q(start_at__hour__lte=10)
if self.fallback_shifts.count() != 0: is_restricted = Q(calendar__restricted=True)
return
day_shifts = ~is_night_shift & ~is_last_night & needs_fallback day_shifts = Bucket(~is_night_shift & ~is_last_night)
night_shifts = is_night_shift & ~is_last_night & needs_fallback night_shifts = Bucket(is_night_shift & ~is_last_night)
shit_shifts = is_last_night & needs_fallback shit_shifts = Bucket(is_last_night)
self._assign_from_bucket(day_shifts) restricted_buckets = [
self._assign_from_bucket(night_shifts) Bucket(Q(calendar=calendar))
self._assign_from_bucket(shit_shifts) for calendar in Calendar.objects.filter(
needs_fallback=True, restricted=True
)
]
def _assign_from_bucket(self, bucket_selector): for bucket, assignments in distribute(
bucket = Event.objects.filter(bucket_selector).annotate( total_slots,
fallback_count=Count("fallbackassignment"), total_assignments,
real_required_helpers=Case( Bucket(
When(required_helpers=0, then=F("room__required_helpers")), needs_fallback,
default=F("required_helpers"), [
Bucket(~is_restricted, [day_shifts, night_shifts, shit_shifts]),
Bucket(is_restricted, restricted_buckets),
],
), ),
) ):
self._assign_from_bucket(bucket, assignments)
def _assign_from_bucket(self, bucket, shift_count):
bucket = bucket.annotate(fallback_count=Count("fallbackassignment"))
free_bucket = bucket.filter(fallback_count__lt=F("real_required_helpers")) free_bucket = bucket.filter(fallback_count__lt=F("real_required_helpers"))
total_slot_count = bucket.aggregate(sum=Sum("real_required_helpers"))["sum"] total_slot_count = bucket.aggregate(sum=Sum("real_required_helpers"))["sum"]
quota = global_preferences["helper__fallback_quota"]
number_of_team_members = TeamMember.objects.count()
max_shifts_per_member = total_slot_count / max(
number_of_team_members * quota, 1
)
active_team_members = (
TeamMember.objects.filter(~Q(fallback_shifts=None)).count() + 1
)
shifts_per_member = total_slot_count / active_team_members
extra_chance, shift_count = math.modf(
min(max_shifts_per_member, shifts_per_member)
)
shift_count = int(shift_count)
if extra_chance > random():
shift_count += 1
blocked_times = [] blocked_times = []
for shift in self.fallback_shifts.all(): for shift in self.fallback_shifts.all():
blocked_times.append( blocked_times.append(
@ -140,9 +163,7 @@ class TeamMember(models.Model):
while shifts_needed > 0: while shifts_needed > 0:
# this is a bit more complex and uses subqueries and id lists because we want to reuse the query selector for events. # this is a bit more complex and uses subqueries and id lists because we want to reuse the query selector for events.
# maybe there is a good way to transform q-expressions to add prefixes to fields but for now this has to work # maybe there is a good way to transform q-expressions to add prefixes to fields but for now this has to work
candidate_shift_ids = Event.objects.filter(bucket_selector).values( candidate_shift_ids = bucket.values("shift_ptr_id")
"shift_ptr_id"
)
relevant_assignments = FallbackAssignment.objects.filter( relevant_assignments = FallbackAssignment.objects.filter(
shift_id__in=candidate_shift_ids shift_id__in=candidate_shift_ids
) )

View File

@ -10,5 +10,5 @@ def update_calendar(modeladmin, request, queryset):
@admin.register(Calendar) @admin.register(Calendar)
class CalendarAdmin(admin.ModelAdmin): class CalendarAdmin(admin.ModelAdmin):
list_display = ("name", "url", "needs_fallback", "has_errors") list_display = ("name", "url", "needs_fallback", "restricted", "has_errors")
actions = (update_calendar,) actions = (update_calendar,)

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2025-05-16 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("importer", "0004_calendar_name"),
]
operations = [
migrations.AddField(
model_name="calendar",
name="restricted",
field=models.BooleanField(default=False),
),
]

View File

@ -8,6 +8,7 @@ class Calendar(models.Model):
needs_fallback = models.BooleanField(default=False, editable=True) needs_fallback = models.BooleanField(default=False, editable=True)
has_errors = models.BooleanField(default=False, editable=False) has_errors = models.BooleanField(default=False, editable=False)
name = models.CharField(max_length=255, null=True, blank=True) name = models.CharField(max_length=255, null=True, blank=True)
restricted = models.BooleanField(default=False)
def update(self): def update(self):
# break circular import # break circular import
@ -18,5 +19,7 @@ class Calendar(models.Model):
class Event(Shift): class Event(Shift):
objects = models.Manager()
uuid = models.UUIDField(primary_key=True, editable=False) uuid = models.UUIDField(primary_key=True, editable=False)
calendar = models.ForeignKey(Calendar, on_delete=models.CASCADE) calendar = models.ForeignKey(Calendar, on_delete=models.CASCADE)

View File

@ -35,7 +35,7 @@ def metrics(request):
), ),
( (
"fallback_shifts_full", "fallback_shifts_full",
Shift.with_reg_count() Shift.objects.with_reg_count()
.annotate( .annotate(
real_required_helpers=Case( real_required_helpers=Case(
When( When(
@ -46,7 +46,6 @@ def metrics(request):
fallbackassignment_count=Count("fallbackassignment"), fallbackassignment_count=Count("fallbackassignment"),
) )
.filter( .filter(
deleted=False,
reg_count__gte=F("real_required_helpers"), reg_count__gte=F("real_required_helpers"),
fallbackassignment_count__gt=0, fallbackassignment_count__gt=0,
) )
@ -96,16 +95,14 @@ def metrics(request):
), ),
( (
"helpers_required", "helpers_required",
Shift.objects.filter(deleted=False) Shift.objects.annotate(
.annotate(
real_required_helpers=Case( real_required_helpers=Case(
When( When(
required_helpers=0, then=F("room__required_helpers") required_helpers=0, then=F("room__required_helpers")
), ),
default=F("required_helpers"), default=F("required_helpers"),
) )
) ).aggregate(sum=Sum("real_required_helpers"))["sum"]
.aggregate(sum=Sum("real_required_helpers"))["sum"]
or 0, or 0,
), ),
( (
@ -124,15 +121,15 @@ def metrics(request):
*( *(
( (
f'shifts{{room="{room.name}"}}', f'shifts{{room="{room.name}"}}',
Shift.objects.filter(deleted=False, room=room).count(), Shift.objects.filter(room=room).count(),
) )
for room in Room.objects.all() for room in Room.objects.all()
), ),
*( *(
( (
f'shifts_occupied{{room="{room.name}"}}', f'shifts_occupied{{room="{room.name}"}}',
Shift.with_reg_count() Shift.objects.with_reg_count()
.filter(deleted=False, reg_count__gte=1, room=room) .filter(reg_count__gte=1, room=room)
.count(), .count(),
) )
for room in Room.objects.all() for room in Room.objects.all()
@ -140,7 +137,7 @@ def metrics(request):
*( *(
( (
f'shifts_full{{room="{room.name}"}}', f'shifts_full{{room="{room.name}"}}',
Shift.with_reg_count() Shift.objects.with_reg_count()
.annotate( .annotate(
real_required_helpers=Case( real_required_helpers=Case(
When( When(
@ -151,7 +148,7 @@ def metrics(request):
) )
) )
.filter( .filter(
deleted=False, reg_count__gt=0,
reg_count__gte=F("real_required_helpers"), reg_count__gte=F("real_required_helpers"),
room=room, room=room,
) )

View File

@ -33,16 +33,16 @@ def public_dashboard(request):
) )
num_free_shifts = ( num_free_shifts = (
Shift.with_reg_count() Shift.objects.with_reg_count()
.filter(help_wanted, deleted=False, start_at__gte=timezone.now()) .filter(help_wanted, start_at__gte=timezone.now())
.count() .count()
) )
if num_free_shifts > 0: if num_free_shifts > 0:
facts.append(("Zu übernehmende Schichten", num_free_shifts)) facts.append(("Zu übernehmende Schichten", num_free_shifts))
next_free_shifts = ( next_free_shifts = (
Shift.with_reg_count() Shift.objects.with_reg_count()
.filter(help_wanted, start_at__gt=timezone.now(), deleted=False) .filter(help_wanted, start_at__gt=timezone.now())
.order_by("start_at")[:4] .order_by("start_at")[:4]
) )
@ -79,7 +79,7 @@ def team_dashboard(request):
day = today day = today
team_shifts = ( team_shifts = (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.annotate( .annotate(
end_at=ExpressionWrapper( end_at=ExpressionWrapper(
F("start_at") + F("duration"), output_field=models.DateTimeField() F("start_at") + F("duration"), output_field=models.DateTimeField()

View File

@ -21,7 +21,7 @@
<td> <td>
<a href="{% url 'team:helper' reg.helper.pk %}"> <a href="{% url 'team:helper' reg.helper.pk %}">
{{ reg.helper.name }} ({{ reg.helper.phone|stringformat:"s"|slice:"-3:" }}) {{ reg.helper.name }} ({{ reg.helper.phone|stringformat:"s"|slice:"-3:" }})
{% if not reg.helper.asta_confirmed %} {% if asta_required and not reg.helper.asta_confirmed %}
<span class="tag is-danger">AStA</span> <span class="tag is-danger">AStA</span>
{% endif %} {% endif %}
</a> </a>

View File

@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.views.generic.edit import FormMixin from django.views.generic.edit import FormMixin
from dynamic_preferences.registries import global_preferences_registry
from .forms import HELPER_FILTERS, BulkMessage, HelperMessage, HelperShift from .forms import HELPER_FILTERS, BulkMessage, HelperMessage, HelperShift
from .models import ( from .models import (
@ -23,7 +24,7 @@ from .models import (
ShiftRegistration, ShiftRegistration,
) )
# Create your views here. global_preferences = global_preferences_registry.manager()
def index(request): def index(request):
@ -43,7 +44,7 @@ def shift_overview(request):
context = {} context = {}
context["running_shifts"] = ( context["running_shifts"] = (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.prefetch_related("event__calendar") .prefetch_related("event__calendar")
.annotate( .annotate(
checkin_count=Count( checkin_count=Count(
@ -64,7 +65,7 @@ def shift_overview(request):
) )
context["next_shifts"] = ( context["next_shifts"] = (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.prefetch_related("event__calendar") .prefetch_related("event__calendar")
.annotate(checkin_count=checkin_count) .annotate(checkin_count=checkin_count)
.filter( .filter(
@ -79,7 +80,7 @@ def shift_overview(request):
context["next_shifts_per_room"] = filter( context["next_shifts_per_room"] = filter(
lambda x: x is not None, lambda x: x is not None,
( (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.prefetch_related("event__calendar") .prefetch_related("event__calendar")
.filter(room=room, start_at__gt=timezone.now(), deleted=False) .filter(room=room, start_at__gt=timezone.now(), deleted=False)
.order_by("start_at") .order_by("start_at")
@ -98,7 +99,10 @@ def add_helper_shift(self):
@login_required @login_required
def shift_detail(request, pk): def shift_detail(request, pk):
shift = get_object_or_404( shift = get_object_or_404(
Shift.with_reg_count().prefetch_related("shiftregistration_set__helper"), pk=pk Shift.all_objects.with_reg_count().prefetch_related(
"shiftregistration_set__helper"
),
pk=pk,
) )
form = HelperShift() form = HelperShift()
if request.method == "POST": if request.method == "POST":
@ -216,7 +220,7 @@ class ShiftList(LoginRequiredMixin, ListView):
title = "Alle Schichten" title = "Alle Schichten"
def get_queryset(self): def get_queryset(self):
return Shift.with_reg_count() return Shift.all_objects.with_reg_count()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@ -235,7 +239,7 @@ class FreeShiftList(ShiftList):
required_helpers=0 required_helpers=0
) & Q(room__required_helpers__gt=F("reg_count")) ) & Q(room__required_helpers__gt=F("reg_count"))
return ( return (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.annotate( .annotate(
end_at=ExpressionWrapper( end_at=ExpressionWrapper(
F("start_at") + F("duration"), F("start_at") + F("duration"),
@ -264,7 +268,7 @@ class RoomShiftList(ShiftList):
required_helpers=0 required_helpers=0
) & Q(room__required_helpers__gt=F("reg_count")) ) & Q(room__required_helpers__gt=F("reg_count"))
return ( return (
Shift.with_reg_count() Shift.all_objects.with_reg_count()
.filter( .filter(
deleted=False, deleted=False,
room=room, room=room,
@ -288,6 +292,7 @@ class CheckinList(LoginRequiredMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["asta_required"] = global_preferences["helper__enable_asta"]
context["page_range"] = context["paginator"].get_elided_page_range( context["page_range"] = context["paginator"].get_elided_page_range(
context["page_obj"].number context["page_obj"].number
) )

View File

@ -7,6 +7,11 @@ Die Schichten für das Kontaktfestival sind online: https://helfen.kntkt.de
Ignoriere diese SMS, um keine weiteren von uns zu erhalten. Ignoriere diese SMS, um keine weiteren von uns zu erhalten.
``` ```
```
Die Schichten für das Kontaktfestival sind online: https://helfen.kntkt.de
Du erhältst diese SMS, da du als Helfi registriert bist.
```
## Teamschichten ## Teamschichten
``` ```