Add Radeln ohne Alter ride-coordination demo (Django + HTMX + Leaflet)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
steffen1971 2026-06-20 21:20:17 +02:00
commit 5d967c68b5
40 changed files with 2040 additions and 0 deletions

259
rides/models.py Normal file
View file

@ -0,0 +1,259 @@
"""Datenmodell der Fahrten-Koordination.
Kern ist `Ride` mit einem klaren Status-Lebenszyklus und expliziten
Übergangs-Methoden (`claim`, `mark_done`, `cancel`). Die erlaubten Übergänge
und die Sichtbarkeit sind auf dem Modell gekapselt, damit Views und Templates
sie nicht jeweils neu erfinden.
"""
from __future__ import annotations
from django.conf import settings
from django.db import models
class Role(models.TextChoices):
COORDINATOR = "coordinator", "Koordination"
PILOT = "pilot", "Pilot"
REQUESTER = "requester", "Besteller"
class Profile(models.Model):
"""Verknüpft einen Django-User mit seiner Rolle im Projekt."""
user = models.OneToOneField(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile"
)
role = models.CharField(max_length=20, choices=Role.choices)
def __str__(self) -> str:
name = self.user.get_full_name() or self.user.username
return f"{name} ({self.get_role_display()})"
@property
def is_coordinator(self) -> bool:
return self.role == Role.COORDINATOR
@property
def is_pilot(self) -> bool:
return self.role == Role.PILOT
@property
def is_requester(self) -> bool:
return self.role == Role.REQUESTER
class Availability(models.Model):
"""Zeitfenster, in dem eine Pilot:in fahren kann."""
pilot = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="availabilities"
)
start = models.DateTimeField("Beginn")
end = models.DateTimeField("Ende")
class Meta:
ordering = ["start"]
verbose_name = "Verfügbarkeit"
verbose_name_plural = "Verfügbarkeiten"
def __str__(self) -> str:
return f"{self.pilot} {self.start:%d.%m. %H:%M}{self.end:%H:%M}"
class Einrichtung(models.Model):
"""Partner-Einrichtung (z.B. Pflegeheim)."""
name = models.CharField(max_length=120)
address = models.CharField(max_length=200, blank=True)
contact = models.CharField(max_length=120, blank=True)
class Meta:
ordering = ["name"]
def __str__(self) -> str:
return self.name
class Fahrgast(models.Model):
"""Fahrgast (Senior:in). Volles Profil — der sensibelste Datensatz."""
name = models.CharField(max_length=120)
einrichtung = models.ForeignKey(
Einrichtung,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="fahrgaeste",
)
mobility_notes = models.TextField(
"Hinweise", blank=True, help_text="Mobilität, Vorlieben, Hinweise für die Fahrt."
)
class Meta:
ordering = ["name"]
verbose_name = "Fahrgast"
verbose_name_plural = "Fahrgäste"
def __str__(self) -> str:
return self.name
class RideStatus(models.TextChoices):
REQUESTED = "requested", "Angefragt"
PLANNED = "planned", "Geplant"
CLAIMED = "claimed", "Übernommen"
DONE = "done", "Erledigt"
CANCELLED = "cancelled", "Abgesagt"
# Statusfarben — einmal definiert, von Modell, Karte und Statistik genutzt.
STATUS_COLORS = {
RideStatus.REQUESTED.value: "#d98a3d",
RideStatus.PLANNED.value: "#3a7be8",
RideStatus.CLAIMED.value: "#7b54e8",
RideStatus.DONE.value: "#2f8f5b",
RideStatus.CANCELLED.value: "#9aa0a6",
}
_CLAIMABLE = {RideStatus.REQUESTED, RideStatus.PLANNED}
_CLOSED = {RideStatus.DONE, RideStatus.CANCELLED}
class RideQuerySet(models.QuerySet):
def open_for_pilots(self) -> RideQuerySet:
"""Fahrten, die eine Pilot:in übernehmen kann."""
return self.filter(status__in=_CLAIMABLE, assigned_pilot__isnull=True)
def upcoming(self) -> RideQuerySet:
return self.exclude(status__in=_CLOSED)
def for_map(self) -> RideQuerySet:
return self.exclude(status=RideStatus.CANCELLED)
def visible_to(self, user) -> RideQuerySet:
"""Fahrten, die dieser User sehen darf — spiegelt `Ride.visible_to`.
Wird für die Karte genutzt, damit dort keine Daten auftauchen, die der
User auf der Detailseite nicht sehen dürfte.
"""
profile = getattr(user, "profile", None)
if profile is None:
return self.none()
if profile.is_coordinator:
return self.for_map()
if profile.is_pilot:
return self.for_map().filter(
models.Q(assigned_pilot=user)
| models.Q(assigned_pilot__isnull=True, status__in=_CLAIMABLE)
)
return self.for_map().filter(requested_by=user)
class Ride(models.Model):
"""Eine Rikscha-Fahrt mit Status-Lebenszyklus (das Herzstück)."""
fahrgast = models.ForeignKey(Fahrgast, on_delete=models.PROTECT, related_name="rides")
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="requested_rides",
)
assigned_pilot = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="rides",
)
scheduled_at = models.DateTimeField("Termin")
status = models.CharField(
max_length=20, choices=RideStatus.choices, default=RideStatus.REQUESTED
)
pickup_label = models.CharField("Abholort", max_length=200)
pickup_lat = models.FloatField()
pickup_lng = models.FloatField()
dest_label = models.CharField("Ziel", max_length=200)
dest_lat = models.FloatField()
dest_lng = models.FloatField()
route_geojson = models.JSONField(
null=True, blank=True, help_text="Gecachte Route (Start→Ziel) vom Routing-Dienst."
)
note = models.TextField("Notiz zur Fahrt", blank=True)
objects = RideQuerySet.as_manager()
class Meta:
ordering = ["scheduled_at"]
verbose_name = "Fahrt"
verbose_name_plural = "Fahrten"
def __str__(self) -> str:
return f"{self.fahrgast} · {self.scheduled_at:%d.%m. %H:%M} ({self.get_status_display()})"
# --- Erlaubte Übergänge (eine Quelle der Wahrheit) -------------------
@property
def can_be_claimed(self) -> bool:
return self.status in _CLAIMABLE
@property
def can_be_finished(self) -> bool:
return self.status == RideStatus.CLAIMED
@property
def can_be_cancelled(self) -> bool:
return self.status not in _CLOSED
# --- Status-Übergänge (explizit, getestet) ---------------------------
def claim(self, pilot) -> None:
"""Eine Pilot:in übernimmt die Fahrt."""
if not self.can_be_claimed:
raise ValueError("Nur angefragte oder geplante Fahrten können übernommen werden.")
self.assigned_pilot = pilot
self.status = RideStatus.CLAIMED
self.save(update_fields=["assigned_pilot", "status"])
def mark_done(self) -> None:
if not self.can_be_finished:
raise ValueError("Nur übernommene Fahrten können erledigt werden.")
self.status = RideStatus.DONE
self.save(update_fields=["status"])
def cancel(self) -> None:
if not self.can_be_cancelled:
raise ValueError("Erledigte oder abgesagte Fahrten können nicht abgesagt werden.")
self.status = RideStatus.CANCELLED
self.save(update_fields=["status"])
@property
def status_color(self) -> str:
return STATUS_COLORS[self.status]
# --- Sichtbarkeit (Rollen-Rechte am Modell gekapselt) ----------------
def visible_to(self, user) -> bool:
"""Darf dieser User die Fahrt überhaupt sehen?"""
profile = getattr(user, "profile", None)
if profile is None:
return False
if profile.is_coordinator:
return True
if self.requested_by_id == user.id or self.assigned_pilot_id == user.id:
return True
# Offene Fahrt: jede Pilot:in darf sie sehen, um über das Übernehmen zu entscheiden.
return profile.is_pilot and self.assigned_pilot_id is None and self.status in _CLAIMABLE
def notes_visible_to(self, user) -> bool:
"""Sichtbarkeit der Pflege-Hinweise.
Aktuell identisch zur Sichtbarkeit der Fahrt: wer die Fahrt sehen darf,
sieht auch ihre Hinweise. Bewusst als eigener Seam gehalten, damit die
Regel später verschärft werden kann, ohne Views/Templates anzufassen.
"""
return self.visible_to(user)