radeln/rides/models.py
steffen1971 5d967c68b5 Add Radeln ohne Alter ride-coordination demo (Django + HTMX + Leaflet)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:20:17 +02:00

259 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)