Compare commits
2 Commits
d034e39cb8
...
c69dab22a5
Author | SHA1 | Date |
---|---|---|
![]() |
c69dab22a5 | |
![]() |
f4881f05f0 |
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -28,11 +28,18 @@ class Room(models.Model):
|
||||||
return self.shift_set.filter(deleted=False)
|
return self.shift_set.filter(deleted=False)
|
||||||
|
|
||||||
|
|
||||||
class ShiftManager(models.Manager):
|
class Shift(models.Model):
|
||||||
def with_reg_count(self):
|
room = models.ForeignKey(Room, on_delete=models.RESTRICT)
|
||||||
return (
|
start_at = models.DateTimeField(db_index=True)
|
||||||
self.get_queryset()
|
duration = models.DurationField()
|
||||||
.annotate(
|
required_helpers = models.IntegerField(
|
||||||
|
default=0, help_text="When this is set to zero, the room value is used instead."
|
||||||
|
)
|
||||||
|
description = models.TextField(blank=True, default="")
|
||||||
|
deleted = models.BooleanField(default=False, db_index=True)
|
||||||
|
|
||||||
|
def with_reg_count():
|
||||||
|
return Shift.objects.annotate(
|
||||||
reg_count=Count(
|
reg_count=Count(
|
||||||
"shiftregistration",
|
"shiftregistration",
|
||||||
distinct=True,
|
distinct=True,
|
||||||
|
@ -43,36 +50,7 @@ class ShiftManager(models.Manager):
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
).select_related("room")
|
||||||
.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):
|
|
||||||
all_objects = ShiftManager()
|
|
||||||
objects = DisplayShiftManager()
|
|
||||||
|
|
||||||
room = models.ForeignKey(Room, on_delete=models.RESTRICT)
|
|
||||||
start_at = models.DateTimeField(db_index=True)
|
|
||||||
duration = models.DurationField()
|
|
||||||
required_helpers = models.IntegerField(
|
|
||||||
default=0, help_text="When this is set to zero, the room value is used instead."
|
|
||||||
)
|
|
||||||
description = models.TextField(blank=True, default="")
|
|
||||||
deleted = models.BooleanField(default=False, db_index=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.room.name}: {self.start_at}"
|
return f"{self.room.name}: {self.start_at}"
|
||||||
|
@ -145,10 +123,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__lt=new_shift_end)
|
Q(shift_end__gt=shift.start_at, shift_end__lte=new_shift_end)
|
||||||
|
|
|
|
||||||
# Case 3: Completely encompasses the new shift
|
# Case 3: Completely encompasses the new shift
|
||||||
Q(shift__start_at__lt=shift.start_at, shift_end__gt=new_shift_end)
|
Q(shift__start_at__lte=shift.start_at, shift_end__gte=new_shift_end)
|
||||||
)
|
)
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.all_objects.get(pk=instance.id)
|
prev = Shift.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[
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -13,13 +13,11 @@
|
||||||
<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 shift.restricted %}
|
{% if has_overlap %}
|
||||||
<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>
|
||||||
|
|
|
@ -44,32 +44,11 @@ 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
|
start_at=self.base_shift.start_at + timedelta(hours=4),
|
||||||
+ self.base_shift.duration
|
|
||||||
+ timedelta(hours=1),
|
|
||||||
duration=timedelta(hours=1),
|
duration=timedelta(hours=1),
|
||||||
required_helpers=1,
|
required_helpers=1,
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,6 @@ 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"),
|
||||||
|
|
|
@ -8,7 +8,6 @@ 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
|
||||||
|
@ -31,7 +30,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(
|
||||||
start_at__gte=event_start_at, start_at__lte=event_end_at
|
deleted=False, start_at__gte=event_start_at, start_at__lte=event_end_at
|
||||||
)
|
)
|
||||||
.datetimes("start_at", "day")
|
.datetimes("start_at", "day")
|
||||||
.all()
|
.all()
|
||||||
|
@ -66,12 +65,13 @@ 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.objects.with_reg_count()
|
Shift.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,19 +98,7 @@ 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()
|
||||||
|
@ -203,12 +191,7 @@ def register(request):
|
||||||
|
|
||||||
@event_state
|
@event_state
|
||||||
def shift(request, shiftid):
|
def shift(request, shiftid):
|
||||||
shift = get_object_or_404(
|
shift = get_object_or_404(Shift.with_reg_count(), pk=shiftid)
|
||||||
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"],
|
||||||
|
@ -218,14 +201,15 @@ def shift(request, shiftid):
|
||||||
"shift": shift,
|
"shift": shift,
|
||||||
"shift_form": EmptyForm,
|
"shift_form": EmptyForm,
|
||||||
}
|
}
|
||||||
context["can_register"] = (
|
|
||||||
shift.required_helpers > shift.registration_count()
|
|
||||||
or shift.required_helpers == 0
|
|
||||||
and shift.room.required_helpers > shift.registration_count()
|
|
||||||
) and not shift.restricted
|
|
||||||
|
|
||||||
# this currently ignores date/time
|
# this currently ignores date/time
|
||||||
request.session["last_seen_shift"] = shiftid
|
request.session["last_seen_shift"] = shiftid
|
||||||
|
if (
|
||||||
|
shift.required_helpers > shift.registration_count()
|
||||||
|
or shift.required_helpers == 0
|
||||||
|
and shift.room.required_helpers > shift.registration_count()
|
||||||
|
):
|
||||||
|
context["can_register"] = True
|
||||||
|
|
||||||
if helper:
|
if helper:
|
||||||
context["helper"] = helper
|
context["helper"] = helper
|
||||||
|
|
|
@ -1,56 +0,0 @@
|
||||||
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
|
|
|
@ -12,8 +12,6 @@ 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")
|
||||||
|
@ -42,38 +40,8 @@ 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
|
||||||
|
@ -88,38 +56,47 @@ 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)
|
||||||
|
|
||||||
is_restricted = Q(calendar__restricted=True)
|
if self.fallback_shifts.count() != 0:
|
||||||
|
return
|
||||||
|
|
||||||
day_shifts = Bucket(~is_night_shift & ~is_last_night)
|
day_shifts = ~is_night_shift & ~is_last_night & needs_fallback
|
||||||
night_shifts = Bucket(is_night_shift & ~is_last_night)
|
night_shifts = is_night_shift & ~is_last_night & needs_fallback
|
||||||
shit_shifts = Bucket(is_last_night)
|
shit_shifts = is_last_night & needs_fallback
|
||||||
|
|
||||||
restricted_buckets = [
|
self._assign_from_bucket(day_shifts)
|
||||||
Bucket(Q(calendar=calendar))
|
self._assign_from_bucket(night_shifts)
|
||||||
for calendar in Calendar.objects.filter(
|
self._assign_from_bucket(shit_shifts)
|
||||||
needs_fallback=True, restricted=True
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
for bucket, assignments in distribute(
|
def _assign_from_bucket(self, bucket_selector):
|
||||||
total_slots,
|
bucket = Event.objects.filter(bucket_selector).annotate(
|
||||||
total_assignments,
|
fallback_count=Count("fallbackassignment"),
|
||||||
Bucket(
|
real_required_helpers=Case(
|
||||||
needs_fallback,
|
When(required_helpers=0, then=F("room__required_helpers")),
|
||||||
[
|
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(
|
||||||
|
@ -163,7 +140,9 @@ 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 = bucket.values("shift_ptr_id")
|
candidate_shift_ids = Event.objects.filter(bucket_selector).values(
|
||||||
|
"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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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", "restricted", "has_errors")
|
list_display = ("name", "url", "needs_fallback", "has_errors")
|
||||||
actions = (update_calendar,)
|
actions = (update_calendar,)
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
# 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),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -8,7 +8,6 @@ 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
|
||||||
|
@ -19,7 +18,5 @@ 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)
|
||||||
|
|
|
@ -35,7 +35,7 @@ def metrics(request):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"fallback_shifts_full",
|
"fallback_shifts_full",
|
||||||
Shift.objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.annotate(
|
.annotate(
|
||||||
real_required_helpers=Case(
|
real_required_helpers=Case(
|
||||||
When(
|
When(
|
||||||
|
@ -46,6 +46,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
@ -95,14 +96,16 @@ def metrics(request):
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"helpers_required",
|
"helpers_required",
|
||||||
Shift.objects.annotate(
|
Shift.objects.filter(deleted=False)
|
||||||
|
.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,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -121,15 +124,15 @@ def metrics(request):
|
||||||
*(
|
*(
|
||||||
(
|
(
|
||||||
f'shifts{{room="{room.name}"}}',
|
f'shifts{{room="{room.name}"}}',
|
||||||
Shift.objects.filter(room=room).count(),
|
Shift.objects.filter(deleted=False, 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.objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.filter(reg_count__gte=1, room=room)
|
.filter(deleted=False, reg_count__gte=1, room=room)
|
||||||
.count(),
|
.count(),
|
||||||
)
|
)
|
||||||
for room in Room.objects.all()
|
for room in Room.objects.all()
|
||||||
|
@ -137,7 +140,7 @@ def metrics(request):
|
||||||
*(
|
*(
|
||||||
(
|
(
|
||||||
f'shifts_full{{room="{room.name}"}}',
|
f'shifts_full{{room="{room.name}"}}',
|
||||||
Shift.objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.annotate(
|
.annotate(
|
||||||
real_required_helpers=Case(
|
real_required_helpers=Case(
|
||||||
When(
|
When(
|
||||||
|
@ -148,7 +151,7 @@ def metrics(request):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
reg_count__gt=0,
|
deleted=False,
|
||||||
reg_count__gte=F("real_required_helpers"),
|
reg_count__gte=F("real_required_helpers"),
|
||||||
room=room,
|
room=room,
|
||||||
)
|
)
|
||||||
|
|
|
@ -33,16 +33,16 @@ def public_dashboard(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
num_free_shifts = (
|
num_free_shifts = (
|
||||||
Shift.objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.filter(help_wanted, start_at__gte=timezone.now())
|
.filter(help_wanted, deleted=False, 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.objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.filter(help_wanted, start_at__gt=timezone.now())
|
.filter(help_wanted, start_at__gt=timezone.now(), deleted=False)
|
||||||
.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.all_objects.with_reg_count()
|
Shift.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()
|
||||||
|
|
|
@ -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 asta_required and not reg.helper.asta_confirmed %}
|
{% if not reg.helper.asta_confirmed %}
|
||||||
<span class="tag is-danger">AStA</span>
|
<span class="tag is-danger">AStA</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -11,7 +11,6 @@ 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 (
|
||||||
|
@ -24,7 +23,7 @@ from .models import (
|
||||||
ShiftRegistration,
|
ShiftRegistration,
|
||||||
)
|
)
|
||||||
|
|
||||||
global_preferences = global_preferences_registry.manager()
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
|
@ -44,7 +43,7 @@ def shift_overview(request):
|
||||||
|
|
||||||
context = {}
|
context = {}
|
||||||
context["running_shifts"] = (
|
context["running_shifts"] = (
|
||||||
Shift.all_objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.prefetch_related("event__calendar")
|
.prefetch_related("event__calendar")
|
||||||
.annotate(
|
.annotate(
|
||||||
checkin_count=Count(
|
checkin_count=Count(
|
||||||
|
@ -65,7 +64,7 @@ def shift_overview(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
context["next_shifts"] = (
|
context["next_shifts"] = (
|
||||||
Shift.all_objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.prefetch_related("event__calendar")
|
.prefetch_related("event__calendar")
|
||||||
.annotate(checkin_count=checkin_count)
|
.annotate(checkin_count=checkin_count)
|
||||||
.filter(
|
.filter(
|
||||||
|
@ -80,7 +79,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.all_objects.with_reg_count()
|
Shift.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")
|
||||||
|
@ -99,10 +98,7 @@ 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.all_objects.with_reg_count().prefetch_related(
|
Shift.with_reg_count().prefetch_related("shiftregistration_set__helper"), pk=pk
|
||||||
"shiftregistration_set__helper"
|
|
||||||
),
|
|
||||||
pk=pk,
|
|
||||||
)
|
)
|
||||||
form = HelperShift()
|
form = HelperShift()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
|
@ -220,7 +216,7 @@ class ShiftList(LoginRequiredMixin, ListView):
|
||||||
title = "Alle Schichten"
|
title = "Alle Schichten"
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Shift.all_objects.with_reg_count()
|
return Shift.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)
|
||||||
|
@ -239,7 +235,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.all_objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.annotate(
|
.annotate(
|
||||||
end_at=ExpressionWrapper(
|
end_at=ExpressionWrapper(
|
||||||
F("start_at") + F("duration"),
|
F("start_at") + F("duration"),
|
||||||
|
@ -268,7 +264,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.all_objects.with_reg_count()
|
Shift.with_reg_count()
|
||||||
.filter(
|
.filter(
|
||||||
deleted=False,
|
deleted=False,
|
||||||
room=room,
|
room=room,
|
||||||
|
@ -292,7 +288,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|
5
texts.md
5
texts.md
|
@ -7,11 +7,6 @@ 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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
Loading…
Reference in New Issue