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:
commit
5d967c68b5
40 changed files with 2040 additions and 0 deletions
259
rides/models.py
Normal file
259
rides/models.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue