259 lines
8.3 KiB
Python
259 lines
8.3 KiB
Python
"""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)
|