main #1
|
@ -78,3 +78,19 @@ class FallbackQuota(types.FloatPreference):
|
||||||
section = helper
|
section = helper
|
||||||
name = "fallback_quota"
|
name = "fallback_quota"
|
||||||
default = 0.7
|
default = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@global_preferences_registry.register
|
||||||
|
class EventStartAt(types.DatePreference):
|
||||||
|
section = helper
|
||||||
|
name = "event_start_at"
|
||||||
|
default = datetime.date(2024, 5, 24)
|
||||||
|
help_text = "The start date and time of the event. Date navigation will only show days between start and end time."
|
||||||
|
|
||||||
|
|
||||||
|
@global_preferences_registry.register
|
||||||
|
class EventEndAt(types.DatePreference):
|
||||||
|
section = helper
|
||||||
|
name = "event_end_at"
|
||||||
|
default = datetime.date(2025, 5, 28)
|
||||||
|
help_text = "The end date and time of the event. Date navigation will only show days between start and end time."
|
||||||
|
|
|
@ -97,6 +97,40 @@ class Helper(models.Model):
|
||||||
token.send()
|
token.send()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
def has_overlapping_shift(self, shift):
|
||||||
|
new_shift_end = shift.start_at + shift.duration
|
||||||
|
|
||||||
|
return (
|
||||||
|
ShiftRegistration.objects.annotate(
|
||||||
|
shift_end=ExpressionWrapper(
|
||||||
|
F("shift__start_at") + F("shift__duration"),
|
||||||
|
output_field=models.DateTimeField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
helper=self,
|
||||||
|
shift__deleted=False,
|
||||||
|
state__in=[
|
||||||
|
ShiftRegistration.RegState.REGISTERED,
|
||||||
|
ShiftRegistration.RegState.CHECKED_IN,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
# Case 1: Start time falls between new shift's start and end
|
||||||
|
Q(
|
||||||
|
shift__start_at__gte=shift.start_at,
|
||||||
|
shift__start_at__lt=new_shift_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)
|
||||||
|
|
|
||||||
|
# Case 3: Completely encompasses the new shift
|
||||||
|
Q(shift__start_at__lte=shift.start_at, shift_end__gte=new_shift_end)
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
# current or next shift
|
# current or next shift
|
||||||
def important_shift(self):
|
def important_shift(self):
|
||||||
ret = (
|
ret = (
|
||||||
|
|
|
@ -13,7 +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 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>
|
||||||
|
{% else %}
|
||||||
<div class="notification">Diese Schicht ist bereits besetzt.</div>
|
<div class="notification">Diese Schicht ist bereits besetzt.</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p>
|
<p>
|
||||||
|
|
|
@ -1,3 +1,143 @@
|
||||||
from django.test import TestCase
|
from datetime import timedelta
|
||||||
|
|
||||||
# Create your tests here.
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Helper, Room, Shift, ShiftRegistration
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftOverlapTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Create a room
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Test Room", required_helpers=1, meeting_location="Test Location"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a helper
|
||||||
|
self.helper = Helper.objects.create(
|
||||||
|
phone="+491234567890", name="Test Helper", number_validated=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a base shift for testing overlaps
|
||||||
|
self.base_shift = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=timezone.now() + timedelta(hours=1),
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register the helper for the base shift
|
||||||
|
self.base_registration = ShiftRegistration.objects.create(
|
||||||
|
shift=self.base_shift,
|
||||||
|
helper=self.helper,
|
||||||
|
state=ShiftRegistration.RegState.REGISTERED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_overlap_before(self):
|
||||||
|
"""Test a shift that ends before the base shift starts"""
|
||||||
|
shift_before = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at - timedelta(hours=3),
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.helper.has_overlapping_shift(shift_before))
|
||||||
|
|
||||||
|
def test_no_overlap_after(self):
|
||||||
|
"""Test a shift that starts after the base shift ends"""
|
||||||
|
shift_after = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at + timedelta(hours=4),
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.helper.has_overlapping_shift(shift_after))
|
||||||
|
|
||||||
|
def test_overlap_start(self):
|
||||||
|
"""Test a shift that starts during the base shift"""
|
||||||
|
shift_overlap_start = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at + timedelta(hours=1),
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.helper.has_overlapping_shift(shift_overlap_start))
|
||||||
|
|
||||||
|
def test_overlap_end(self):
|
||||||
|
"""Test a shift that ends during the base shift"""
|
||||||
|
shift_overlap_end = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at - timedelta(hours=1),
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.helper.has_overlapping_shift(shift_overlap_end))
|
||||||
|
|
||||||
|
def test_overlap_contained(self):
|
||||||
|
"""Test a shift that is completely contained within the base shift"""
|
||||||
|
shift_contained = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at + timedelta(minutes=30),
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.helper.has_overlapping_shift(shift_contained))
|
||||||
|
|
||||||
|
def test_overlap_contains(self):
|
||||||
|
"""Test a shift that completely contains the base shift"""
|
||||||
|
shift_contains = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at - timedelta(hours=1),
|
||||||
|
duration=timedelta(hours=4),
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.helper.has_overlapping_shift(shift_contains))
|
||||||
|
|
||||||
|
def test_exact_same_time(self):
|
||||||
|
"""Test a shift that has exactly the same time as the base shift"""
|
||||||
|
shift_same_time = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at,
|
||||||
|
duration=self.base_shift.duration,
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(self.helper.has_overlapping_shift(shift_same_time))
|
||||||
|
|
||||||
|
def test_deleted_shift_no_overlap(self):
|
||||||
|
"""Test that deleted shifts are not considered for overlap"""
|
||||||
|
self.base_shift.deleted = True
|
||||||
|
self.base_shift.save()
|
||||||
|
|
||||||
|
shift_same_time = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at,
|
||||||
|
duration=self.base_shift.duration,
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time))
|
||||||
|
|
||||||
|
def test_cancelled_registration_no_overlap(self):
|
||||||
|
"""Test that cancelled registrations are not considered for overlap"""
|
||||||
|
self.base_registration.state = ShiftRegistration.RegState.CANCELED
|
||||||
|
self.base_registration.save()
|
||||||
|
|
||||||
|
shift_same_time = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at,
|
||||||
|
duration=self.base_shift.duration,
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time))
|
||||||
|
|
||||||
|
def test_failed_registration_no_overlap(self):
|
||||||
|
"""Test that failed registrations are not considered for overlap"""
|
||||||
|
self.base_registration.state = ShiftRegistration.RegState.FAILED
|
||||||
|
self.base_registration.save()
|
||||||
|
|
||||||
|
shift_same_time = Shift.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
start_at=self.base_shift.start_at,
|
||||||
|
duration=self.base_shift.duration,
|
||||||
|
required_helpers=1,
|
||||||
|
)
|
||||||
|
self.assertIsNone(self.helper.has_overlapping_shift(shift_same_time))
|
||||||
|
|
|
@ -26,8 +26,16 @@ def index(request):
|
||||||
|
|
||||||
days = cache.get("event_days")
|
days = cache.get("event_days")
|
||||||
if not days:
|
if not days:
|
||||||
days = Shift.objects.filter(deleted=False).datetimes("start_at", "day").all()
|
event_start_at = global_preferences["helper__event_start_at"]
|
||||||
cache.set("event_days", days)
|
event_end_at = global_preferences["helper__event_end_at"]
|
||||||
|
days = (
|
||||||
|
Shift.objects.filter(
|
||||||
|
deleted=False, start_at__gte=event_start_at, start_at__lte=event_end_at
|
||||||
|
)
|
||||||
|
.datetimes("start_at", "day")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
cache.set("event_days", days, 60 * 60)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"days": days,
|
"days": days,
|
||||||
|
@ -211,6 +219,13 @@ def shift(request, shiftid):
|
||||||
context["can_register"] = False
|
context["can_register"] = False
|
||||||
if reg[0].can_cancel():
|
if reg[0].can_cancel():
|
||||||
context["can_cancel"] = True
|
context["can_cancel"] = True
|
||||||
|
elif context["can_register"]:
|
||||||
|
# Check for overlapping shifts
|
||||||
|
overlapping_reg = helper.has_overlapping_shift(shift)
|
||||||
|
if overlapping_reg:
|
||||||
|
context["can_register"] = False
|
||||||
|
context["has_overlap"] = True
|
||||||
|
context["overlapping_shift"] = overlapping_reg.shift
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if EmptyForm(request.POST).is_valid():
|
if EmptyForm(request.POST).is_valid():
|
||||||
|
@ -236,6 +251,14 @@ def shift(request, shiftid):
|
||||||
)
|
)
|
||||||
return redirect("index")
|
return redirect("index")
|
||||||
if context["can_register"]:
|
if context["can_register"]:
|
||||||
|
overlapping_reg = helper.has_overlapping_shift(shift)
|
||||||
|
if overlapping_reg:
|
||||||
|
messages.add_message(
|
||||||
|
request,
|
||||||
|
messages.ERROR,
|
||||||
|
"Du hast bereits eine überlappende Schicht zu dieser Zeit.",
|
||||||
|
)
|
||||||
|
return redirect("shift", shiftid=shift.pk)
|
||||||
s = ShiftRegistration(helper=helper, shift=shift)
|
s = ShiftRegistration(helper=helper, shift=shift)
|
||||||
s.save()
|
s.save()
|
||||||
messages.add_message(
|
messages.add_message(
|
||||||
|
|
|
@ -25,11 +25,12 @@ class FallbackAssignmentInline(admin.TabularInline):
|
||||||
model = FallbackAssignment
|
model = FallbackAssignment
|
||||||
ordering = ("shift__start_at",)
|
ordering = ("shift__start_at",)
|
||||||
readonly_fields = ("shift",)
|
readonly_fields = ("shift",)
|
||||||
|
fk_name = "team_member"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(TeamMember)
|
@admin.register(TeamMember)
|
||||||
class TeamMemberAdmin(admin.ModelAdmin):
|
class TeamMemberAdmin(admin.ModelAdmin):
|
||||||
fields = ("id", "name", "comment", "url")
|
fields = ("id", "name", "comment", "url", "pin")
|
||||||
readonly_fields = ("id", "url")
|
readonly_fields = ("id", "url")
|
||||||
list_display = ("name", "comment", "shift_count")
|
list_display = ("name", "comment", "shift_count")
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 5.0.4 on 2025-05-14 11:39
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("fallback", "0008_alter_teammember_comment"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="fallbackassignment",
|
||||||
|
name="traded_to",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="received_trades",
|
||||||
|
to="fallback.teammember",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 5.0.4 on 2025-05-14 11:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import shiftregister.fallback.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("fallback", "0009_fallbackassignment_traded_to"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="teammember",
|
||||||
|
name="pin",
|
||||||
|
field=models.IntegerField(
|
||||||
|
default=shiftregister.fallback.models.generate_pin
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -2,7 +2,7 @@ import math
|
||||||
import secrets
|
import secrets
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from random import random
|
from random import randint, random
|
||||||
|
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
from django.db.models import Count, Exists, ExpressionWrapper, Max, OuterRef, Sum
|
from django.db.models import Count, Exists, ExpressionWrapper, Max, OuterRef, Sum
|
||||||
|
@ -17,22 +17,28 @@ def generate_id():
|
||||||
return int.from_bytes(secrets.token_bytes(3), byteorder="big")
|
return int.from_bytes(secrets.token_bytes(3), byteorder="big")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pin():
|
||||||
|
return randint(1000, 9999)
|
||||||
|
|
||||||
|
|
||||||
class TeamMember(models.Model):
|
class TeamMember(models.Model):
|
||||||
id = models.IntegerField(default=generate_id, editable=False, primary_key=True)
|
id = models.IntegerField(default=generate_id, editable=False, primary_key=True)
|
||||||
name = models.CharField(max_length=100)
|
name = models.CharField(max_length=100)
|
||||||
comment = models.CharField(max_length=100, blank=True, default="")
|
comment = models.CharField(max_length=100, blank=True, default="")
|
||||||
fallback_shifts = models.ManyToManyField(Shift, through="FallbackAssignment")
|
pin = models.IntegerField(default=generate_pin)
|
||||||
|
fallback_shifts = models.ManyToManyField(
|
||||||
|
Shift, through="FallbackAssignment", through_fields=("team_member", "shift")
|
||||||
|
)
|
||||||
|
|
||||||
def url(self):
|
def url(self):
|
||||||
return "https://helfen.kntkt.de" + reverse(
|
return "https://helfen.kntkt.de" + reverse(
|
||||||
"my_fallback_shifts",
|
"my_fallback_shifts",
|
||||||
kwargs={
|
kwargs={"team_member_id": self.url_id()},
|
||||||
"team_member_id": urlsafe_b64encode(
|
|
||||||
self.id.to_bytes(3, byteorder="big")
|
|
||||||
).decode("utf-8")
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def url_id(self):
|
||||||
|
return urlsafe_b64encode(self.id.to_bytes(3, byteorder="big")).decode("utf-8")
|
||||||
|
|
||||||
def assign_random_shifts(self):
|
def assign_random_shifts(self):
|
||||||
needs_fallback = Q(deleted=False, calendar__needs_fallback=True)
|
needs_fallback = Q(deleted=False, calendar__needs_fallback=True)
|
||||||
|
|
||||||
|
@ -199,6 +205,15 @@ class FallbackAssignment(models.Model):
|
||||||
shift = models.ForeignKey(Shift, on_delete=models.CASCADE)
|
shift = models.ForeignKey(Shift, on_delete=models.CASCADE)
|
||||||
team_member = models.ForeignKey(TeamMember, on_delete=models.CASCADE)
|
team_member = models.ForeignKey(TeamMember, on_delete=models.CASCADE)
|
||||||
was_full = models.BooleanField(default=False)
|
was_full = models.BooleanField(default=False)
|
||||||
|
traded_to = models.ForeignKey(
|
||||||
|
TeamMember,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="received_trades",
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
if self.traded_to:
|
||||||
|
return f"{self.shift} {self.team_member.name} -> {self.traded_to.name}"
|
||||||
return f"{self.shift} {self.team_member.name}"
|
return f"{self.shift} {self.team_member.name}"
|
||||||
|
|
|
@ -9,12 +9,42 @@
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<a href="{% url 'pages:view' 'team_faq' %}">Häufig gestellte Fragen zu Teamschichten</a>
|
<a href="{% url 'pages:view' 'team_faq' %}">Häufig gestellte Fragen zu Teamschichten</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h4 class="subtitle">Schicht übernehmen</h4>
|
||||||
|
<form method="POST">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
{{ trade_form.assignment_id }}
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
{{ trade_form.pin }}
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" name="take_shift" class="button is-info">Übernehmen/Entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class="field">
|
||||||
|
{% for field in trade_form %}
|
||||||
|
{% for error in field.errors %}
|
||||||
|
<p class="help is-danger">{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if assignments %}
|
{% if assignments %}
|
||||||
{% if is_draw %}
|
{% if is_draw %}
|
||||||
<pre class="mb-5 select_all">Hallo {{ team_member.name }}, hier deine Teamschichten für das Festival:
|
<pre class="mb-5 select_all">Hallo {{ team_member.name }}, hier deine Teamschichten für das Festival:
|
||||||
|
|
||||||
{{ team_member.url }}
|
{{ team_member.url }}
|
||||||
|
|
||||||
|
Deine PIN um Schichten zu übernehmen ist {{ team_member.pin }}
|
||||||
|
|
||||||
Deine Schichten werden in den nächsten Tagen weniger werden, wenn wir alle Schichten unter mehr Teammitgliedern verteilen.
|
Deine Schichten werden in den nächsten Tagen weniger werden, wenn wir alle Schichten unter mehr Teammitgliedern verteilen.
|
||||||
Du kannst unter dem Link immer nachschauen, welche Schichten du noch hast und welche schon von Helfis belegt sind.
|
Du kannst unter dem Link immer nachschauen, welche Schichten du noch hast und welche schon von Helfis belegt sind.
|
||||||
Bei Schichten mit mehreren Personen, bei denen nicht alle von Helfis belegt sind, koordiniere dich bitte mit den anderen Teammitgliedern, wer von euch die Schicht übernimmt.
|
Bei Schichten mit mehreren Personen, bei denen nicht alle von Helfis belegt sind, koordiniere dich bitte mit den anderen Teammitgliedern, wer von euch die Schicht übernimmt.
|
||||||
|
@ -26,6 +56,7 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>Tausch-ID</th>
|
||||||
<th>Wann</th>
|
<th>Wann</th>
|
||||||
<th>Wie lange</th>
|
<th>Wie lange</th>
|
||||||
<th>Wo</th>
|
<th>Wo</th>
|
||||||
|
@ -38,13 +69,19 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift
|
||||||
{% for assignment in assignments %}
|
{% for assignment in assignments %}
|
||||||
{% with assignment.shift as shift %}
|
{% with assignment.shift as shift %}
|
||||||
<tr{% if shift.registration_count == shift.required_helpers|default:shift.room.required_helpers or assignment.was_full %} class="has-text-grey" style="text-decoration: line-through;"{% endif %}>
|
<tr{% if shift.registration_count == shift.required_helpers|default:shift.room.required_helpers or assignment.was_full %} class="has-text-grey" style="text-decoration: line-through;"{% endif %}>
|
||||||
|
<td>{{ assignment.id }} {% if assignment.traded_to %}*{% endif %}</td>
|
||||||
<td>{{ shift.start_at }}</td>
|
<td>{{ shift.start_at }}</td>
|
||||||
<td>{{ shift.duration|duration }}</td>
|
<td>{{ shift.duration|duration }}</td>
|
||||||
<td>{{ shift.room.name }} </td>
|
<td>{{ shift.room.name }} </td>
|
||||||
<td>{{ shift.registration_count }}/{{ shift.required_helpers|default:shift.room.required_helpers }}</td>
|
<td>{{ shift.registration_count }}/{{ shift.required_helpers|default:shift.room.required_helpers }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% for assignment in shift.fallbackassignment_set.all %}
|
{% for fa in shift.fallbackassignment_set.all %}
|
||||||
{{ assignment.team_member.name }}{% if not forloop.last %}, {% endif %}
|
{% if fa.traded_to %}
|
||||||
|
{{ fa.traded_to.name }}
|
||||||
|
{% else %}
|
||||||
|
{{ fa.team_member.name }}
|
||||||
|
{% endif %}
|
||||||
|
{% if not forloop.last %}, {% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -61,7 +98,7 @@ Diese Schichtzuteilung wurde maschinell erstellt und ist auch ohne Unterschrift
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-success" type="submit">Schichten zulosen</button>
|
<button class="button is-success" type="submit" name="draw_shifts">Schichten zulosen</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
Noch keine Schichten zugewiesen, bitte wende dich an den Infopoint.
|
Noch keine Schichten zugewiesen, bitte wende dich an den Infopoint.
|
||||||
|
|
|
@ -1,3 +1,171 @@
|
||||||
from django.test import TestCase
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
# Create your tests here.
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from shiftregister.fallback.models import FallbackAssignment, TeamMember
|
||||||
|
from shiftregister.importer.models import Calendar, Event, Room
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftTradeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Create test data
|
||||||
|
self.room = Room.objects.create(name="Test Room", required_helpers=2)
|
||||||
|
|
||||||
|
self.event = Event.objects.create(
|
||||||
|
uuid=uuid.uuid4(),
|
||||||
|
calendar=Calendar.objects.create(url="https://example.com/calendar"),
|
||||||
|
start_at=timezone.now(),
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
room=self.room,
|
||||||
|
deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.team_member1 = TeamMember.objects.create(name="Team Member 1", pin=1111)
|
||||||
|
|
||||||
|
self.team_member2 = TeamMember.objects.create(name="Team Member 2", pin=2222)
|
||||||
|
|
||||||
|
self.team_member3 = TeamMember.objects.create(name="Team Member 3", pin=3333)
|
||||||
|
|
||||||
|
self.assignment = FallbackAssignment.objects.create(
|
||||||
|
shift=self.event, team_member=self.team_member1, traded_to=self.team_member3
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_invalid_pin_keeps_assignment_unchanged(self):
|
||||||
|
"""Test that wrong PIN prevents any changes to the assignment"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member2.url_id()},
|
||||||
|
),
|
||||||
|
{"take_shift": "true", "assignment_id": self.assignment.id, "pin": "9999"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assignment.refresh_from_db()
|
||||||
|
self.assertEqual(self.assignment.team_member, self.team_member1)
|
||||||
|
self.assertEqual(self.assignment.traded_to, self.team_member3)
|
||||||
|
|
||||||
|
def test_take_shift_updates_traded_to(self):
|
||||||
|
"""Test that taking a shift properly updates the traded_to field"""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member2.url_id()},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"take_shift": "true",
|
||||||
|
"assignment_id": self.assignment.id,
|
||||||
|
"pin": self.team_member2.pin,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignment should now be traded to team_member2
|
||||||
|
self.assignment.refresh_from_db()
|
||||||
|
self.assertEqual(self.assignment.traded_to, self.team_member2)
|
||||||
|
self.assertEqual(
|
||||||
|
self.assignment.team_member, self.team_member1
|
||||||
|
) # Original owner unchanged
|
||||||
|
|
||||||
|
def test_return_shift_clears_traded_to(self):
|
||||||
|
"""Test that returning a shift clears the traded_to field"""
|
||||||
|
|
||||||
|
# Then return it
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member3.url_id()},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"take_shift": "true",
|
||||||
|
"assignment_id": self.assignment.id,
|
||||||
|
"pin": self.team_member3.pin,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignment should be returned (traded_to cleared)
|
||||||
|
self.assignment.refresh_from_db()
|
||||||
|
self.assertIsNone(self.assignment.traded_to)
|
||||||
|
self.assertEqual(
|
||||||
|
self.assignment.team_member, self.team_member1
|
||||||
|
) # Original owner unchanged
|
||||||
|
|
||||||
|
def test_reclaim_shift_clears_traded_to(self):
|
||||||
|
"""Test that original owner can reclaim their shift"""
|
||||||
|
|
||||||
|
# Original owner reclaims it
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member1.url_id()},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"take_shift": "true",
|
||||||
|
"assignment_id": self.assignment.id,
|
||||||
|
"pin": self.team_member1.pin,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignment should be reclaimed (traded_to cleared)
|
||||||
|
self.assignment.refresh_from_db()
|
||||||
|
self.assertIsNone(self.assignment.traded_to)
|
||||||
|
self.assertEqual(self.assignment.team_member, self.team_member1)
|
||||||
|
|
||||||
|
def test_take_shift_from_other_trader(self):
|
||||||
|
"""Test that a member can take over a shift that was already traded to someone else"""
|
||||||
|
# Verify initial state
|
||||||
|
self.assertEqual(self.assignment.team_member, self.team_member1)
|
||||||
|
self.assertEqual(self.assignment.traded_to, self.team_member3)
|
||||||
|
|
||||||
|
# team_member2 takes the shift from team_member3
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member2.url_id()},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"take_shift": "true",
|
||||||
|
"assignment_id": self.assignment.id,
|
||||||
|
"pin": self.team_member2.pin,
|
||||||
|
},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Assignment should now be traded to team_member2
|
||||||
|
self.assignment.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
self.assignment.team_member, self.team_member1
|
||||||
|
) # Original owner unchanged
|
||||||
|
self.assertEqual(self.assignment.traded_to, self.team_member2) # New trader
|
||||||
|
|
||||||
|
def test_form_validation_prevents_changes(self):
|
||||||
|
"""Test that invalid form data doesn't modify any assignments"""
|
||||||
|
initial_count = FallbackAssignment.objects.count()
|
||||||
|
initial_state = FallbackAssignment.objects.values_list(
|
||||||
|
"id", "team_member_id", "traded_to_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": self.team_member1.url_id()},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"take_shift": "true",
|
||||||
|
"assignment_id": "not_a_number",
|
||||||
|
"pin": "also_not_a_number",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# No assignments should be created or modified
|
||||||
|
self.assertEqual(FallbackAssignment.objects.count(), initial_count)
|
||||||
|
current_state = FallbackAssignment.objects.values_list(
|
||||||
|
"id", "team_member_id", "traded_to_id"
|
||||||
|
)
|
||||||
|
self.assertEqual(list(initial_state), list(current_state))
|
||||||
|
|
|
@ -1,13 +1,23 @@
|
||||||
from base64 import urlsafe_b64decode
|
from base64 import urlsafe_b64decode
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Count
|
from django.db.models import Count, Q
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from shiftregister.fallback.models import TeamMember
|
from shiftregister.fallback.models import FallbackAssignment, TeamMember
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
class TradeForm(forms.Form):
|
||||||
|
assignment_id = forms.IntegerField(
|
||||||
|
widget=forms.NumberInput(attrs={"class": "input", "placeholder": "Schicht-ID"})
|
||||||
|
)
|
||||||
|
pin = forms.IntegerField(
|
||||||
|
widget=forms.PasswordInput(attrs={"class": "input", "placeholder": "Deine PIN"})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def my_fallback_shifts(request, team_member_id):
|
def my_fallback_shifts(request, team_member_id):
|
||||||
|
@ -21,17 +31,58 @@ def my_fallback_shifts(request, team_member_id):
|
||||||
team_member = get_object_or_404(TeamMember, pk=team_member_id)
|
team_member = get_object_or_404(TeamMember, pk=team_member_id)
|
||||||
|
|
||||||
is_draw = False
|
is_draw = False
|
||||||
|
trade_form = TradeForm(
|
||||||
|
request.POST
|
||||||
|
if request.method == "POST" and "take_shift" in request.POST
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
team_member.assign_random_shifts()
|
if "draw_shifts" in request.POST:
|
||||||
is_draw = True
|
team_member.assign_random_shifts()
|
||||||
|
is_draw = True
|
||||||
|
elif "take_shift" in request.POST and trade_form.is_valid():
|
||||||
|
assignment_id = trade_form.cleaned_data["assignment_id"]
|
||||||
|
pin = trade_form.cleaned_data["pin"]
|
||||||
|
|
||||||
|
if pin != team_member.pin:
|
||||||
|
messages.error(request, "Ungültige PIN")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
assignment = FallbackAssignment.objects.get(pk=assignment_id)
|
||||||
|
if assignment.team_member == team_member:
|
||||||
|
assignment.traded_to = None
|
||||||
|
messages.success(request, f"Schicht erfolgreich zurückgenommen")
|
||||||
|
elif assignment.traded_to == team_member:
|
||||||
|
assignment.traded_to = None
|
||||||
|
messages.success(request, f"Schicht erfolgreich zurückgegeben")
|
||||||
|
else:
|
||||||
|
assignment.traded_to = team_member
|
||||||
|
messages.success(request, f"Schicht erfolgreich übernommen")
|
||||||
|
assignment.save()
|
||||||
|
return redirect(
|
||||||
|
reverse(
|
||||||
|
"my_fallback_shifts",
|
||||||
|
kwargs={"team_member_id": team_member.url_id()},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except FallbackAssignment.DoesNotExist:
|
||||||
|
messages.error(request, "Ungültige Schicht-ID")
|
||||||
|
|
||||||
|
assignments = (
|
||||||
|
FallbackAssignment.objects.filter(
|
||||||
|
Q(team_member=team_member, traded_to__isnull=True)
|
||||||
|
| Q(traded_to=team_member)
|
||||||
|
)
|
||||||
|
.prefetch_related("shift", "traded_to", "team_member")
|
||||||
|
.order_by("shift__start_at")
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"team_member": team_member,
|
"team_member": team_member,
|
||||||
"assignments": team_member.fallbackassignment_set.order_by(
|
"assignments": assignments,
|
||||||
"shift__start_at"
|
|
||||||
).all(),
|
|
||||||
# "shifts": team_member.fallback_shifts.order_by("start_at").all(),
|
|
||||||
"is_draw": is_draw,
|
"is_draw": is_draw,
|
||||||
|
"trade_form": trade_form,
|
||||||
}
|
}
|
||||||
return render(request, "my_fallback_shifts.html", context)
|
return render(request, "my_fallback_shifts.html", context)
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,12 @@ from django.contrib import admin
|
||||||
from .models import Calendar
|
from .models import Calendar
|
||||||
|
|
||||||
|
|
||||||
|
def update_calendar(modeladmin, request, queryset):
|
||||||
|
for calendar in queryset:
|
||||||
|
calendar.update()
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Calendar)
|
@admin.register(Calendar)
|
||||||
class CalendarAdmin(admin.ModelAdmin):
|
class CalendarAdmin(admin.ModelAdmin):
|
||||||
list_display = ("url", "needs_fallback", "has_errors")
|
list_display = ("url", "needs_fallback", "has_errors")
|
||||||
|
actions = (update_calendar,)
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 5.0.4 on 2025-05-12 22:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("importer", "0002_calendar_needs_fallback"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="calendar",
|
||||||
|
name="url",
|
||||||
|
field=models.URLField(max_length=1000, primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
]
|
|
@ -4,10 +4,17 @@ from shiftregister.app.models import *
|
||||||
|
|
||||||
|
|
||||||
class Calendar(models.Model):
|
class Calendar(models.Model):
|
||||||
url = models.URLField(primary_key=True)
|
url = models.URLField(primary_key=True, max_length=1000)
|
||||||
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)
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
# break circular import
|
||||||
|
from .importer import import_calendar
|
||||||
|
|
||||||
|
self.has_errors = not import_calendar(self)
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class Event(Shift):
|
class Event(Shift):
|
||||||
uuid = models.UUIDField(primary_key=True, editable=False)
|
uuid = models.UUIDField(primary_key=True, editable=False)
|
||||||
|
|
|
@ -7,5 +7,4 @@ from .models import Calendar
|
||||||
@shared_task
|
@shared_task
|
||||||
def import_shifts():
|
def import_shifts():
|
||||||
for calendar in Calendar.objects.all():
|
for calendar in Calendar.objects.all():
|
||||||
calendar.has_errors = not import_calendar(calendar)
|
calendar.update()
|
||||||
calendar.save()
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import environ
|
import environ
|
||||||
|
@ -31,6 +32,9 @@ environ.Env.read_env(BASE_DIR / ".env")
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = env("ENVIRONMENT") == "development"
|
DEBUG = env("ENVIRONMENT") == "development"
|
||||||
|
|
||||||
|
# Check if we're running tests
|
||||||
|
TESTING = "test" in sys.argv
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = env(
|
SECRET_KEY = env(
|
||||||
"SECRET_KEY",
|
"SECRET_KEY",
|
||||||
|
@ -61,6 +65,9 @@ THIRDPARTY_APPS = [
|
||||||
"phonenumber_field",
|
"phonenumber_field",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if DEBUG and not TESTING:
|
||||||
|
THIRDPARTY_APPS += ["debug_toolbar"]
|
||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
"shiftregister.app",
|
"shiftregister.app",
|
||||||
"shiftregister.core",
|
"shiftregister.core",
|
||||||
|
@ -87,6 +94,9 @@ MIDDLEWARE = [
|
||||||
"shiftregister.app.middleware.check_helper",
|
"shiftregister.app.middleware.check_helper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if DEBUG and not TESTING:
|
||||||
|
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||||
|
|
||||||
ROOT_URLCONF = "shiftregister.urls"
|
ROOT_URLCONF = "shiftregister.urls"
|
||||||
|
|
||||||
LOGIN_URL = "/admin/login/"
|
LOGIN_URL = "/admin/login/"
|
||||||
|
@ -260,3 +270,26 @@ LOGGING = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Debug Toolbar settings
|
||||||
|
if DEBUG:
|
||||||
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {
|
||||||
|
"SHOW_TOOLBAR_CALLBACK": lambda request: True, # Always show toolbar in debug mode
|
||||||
|
"SHOW_TEMPLATE_CONTEXT": True,
|
||||||
|
"SQL_WARNING_THRESHOLD": 100, # ms
|
||||||
|
}
|
||||||
|
DEBUG_TOOLBAR_PANELS = [
|
||||||
|
"debug_toolbar.panels.timer.TimerPanel",
|
||||||
|
"debug_toolbar.panels.settings.SettingsPanel",
|
||||||
|
"debug_toolbar.panels.headers.HeadersPanel",
|
||||||
|
"debug_toolbar.panels.request.RequestPanel",
|
||||||
|
"debug_toolbar.panels.sql.SQLPanel",
|
||||||
|
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
|
||||||
|
"debug_toolbar.panels.templates.TemplatesPanel",
|
||||||
|
"debug_toolbar.panels.cache.CachePanel",
|
||||||
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
|
"debug_toolbar.panels.logging.LoggingPanel",
|
||||||
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
|
]
|
||||||
|
|
|
@ -18,7 +18,18 @@
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ shift.room.name }}</td>
|
<td>{{ shift.room.name }}</td>
|
||||||
<td>{{ shift.start_at }}</td>
|
<td>{{ shift.start_at }}</td>
|
||||||
<td>{% for fa in shift.fallbackassignment_set.all %}{% if not fa.was_full %}{{ fa.team_member.name }}{% if not forloop.last %}, {% endif %}{% endif %}{% endfor %}</td>
|
<td>
|
||||||
|
{% for fa in shift.fallbackassignment_set.all %}
|
||||||
|
{% if not fa.was_full %}
|
||||||
|
{% if fa.traded_to %}
|
||||||
|
{{ fa.traded_to.name }} ({{ fa.id }})
|
||||||
|
{% else %}
|
||||||
|
{{ fa.team_member.name }} ({{ fa.id }})
|
||||||
|
{% endif %}
|
||||||
|
{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -59,7 +59,11 @@
|
||||||
{% for fallback in shift.event.fallbackassignment_set.all %}
|
{% for fallback in shift.event.fallbackassignment_set.all %}
|
||||||
<div class="column is-one-quarter">
|
<div class="column is-one-quarter">
|
||||||
<div class="box{% if fallback.was_full %} has-text-grey" style="text-decoration: line-through;{% endif %}">
|
<div class="box{% if fallback.was_full %} has-text-grey" style="text-decoration: line-through;{% endif %}">
|
||||||
{{ fallback.team_member.name }}
|
{% if fallback.traded_to %}
|
||||||
|
{{ fallback.traded_to.name }}
|
||||||
|
{% else %}
|
||||||
|
{{ fallback.team_member.name }}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
|
@ -14,6 +14,7 @@ Including another URLconf
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
@ -28,3 +29,10 @@ urlpatterns = [
|
||||||
path("messages/", include("shiftregister.messaging.urls")),
|
path("messages/", include("shiftregister.messaging.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
import debug_toolbar
|
||||||
|
|
||||||
|
urlpatterns += [
|
||||||
|
path("__debug__/", include(debug_toolbar.urls)),
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
# SMS-Texte
|
||||||
|
|
||||||
|
## Schichten
|
||||||
|
|
||||||
|
```
|
||||||
|
Die Schichten für das Kontaktfestival sind online: https://helfen.kntkt.de
|
||||||
|
Ignoriere diese SMS, um keine weiteren von uns zu erhalten.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teamschichten
|
||||||
|
|
||||||
|
```
|
||||||
|
Ab sofort könnt ihr euch bei (Andi oder) mir melden, um den Link zu euren Teamschichten zu erhalten. Je früher, desto schneller habt ihr die Möglichkeit, die Schichten an eure Freundis weiterzuverteilen :D
|
||||||
|
```
|
||||||
|
|
||||||
|
## Abbauschichterinnerung
|
||||||
|
|
||||||
|
```
|
||||||
|
Deine Schicht beginnt um $UHRZEIT, bitte melde dich auf dem Gelände bei $TEAMMITGLIED https://helfen.kntkt.de/shift/$SHIFT_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Helfifesteinladung
|
||||||
|
|
||||||
|
```
|
||||||
|
Danke für deine Hilfe auf dem Festival. Zur Belohnung schmeißen wir eine Grillparty mit allen Helfis: $fb
|
||||||
|
```
|
Loading…
Reference in New Issue