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