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

0
rides/__init__.py Normal file
View file

35
rides/admin.py Normal file
View file

@ -0,0 +1,35 @@
from django.contrib import admin
from .models import Availability, Einrichtung, Fahrgast, Profile, Ride
@admin.register(Profile)
class ProfileAdmin(admin.ModelAdmin):
list_display = ["user", "role"]
list_filter = ["role"]
@admin.register(Einrichtung)
class EinrichtungAdmin(admin.ModelAdmin):
list_display = ["name", "address", "contact"]
search_fields = ["name"]
@admin.register(Fahrgast)
class FahrgastAdmin(admin.ModelAdmin):
list_display = ["name", "einrichtung"]
list_filter = ["einrichtung"]
search_fields = ["name"]
@admin.register(Availability)
class AvailabilityAdmin(admin.ModelAdmin):
list_display = ["pilot", "start", "end"]
@admin.register(Ride)
class RideAdmin(admin.ModelAdmin):
list_display = ["fahrgast", "scheduled_at", "status", "assigned_pilot"]
list_filter = ["status", "assigned_pilot"]
date_hierarchy = "scheduled_at"
autocomplete_fields = ["fahrgast"]

7
rides/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class RidesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "rides"
verbose_name = "Fahrten"

View file

@ -0,0 +1,5 @@
def user_profile(request):
"""Stellt das Profil (mit Rolle) des eingeloggten Users im Template bereit."""
user = getattr(request, "user", None)
profile = getattr(user, "profile", None) if user and user.is_authenticated else None
return {"profile": profile}

57
rides/forms.py Normal file
View file

@ -0,0 +1,57 @@
from django import forms
from .models import Availability, Ride
_DT = "%Y-%m-%dT%H:%M"
class RideForm(forms.ModelForm):
"""Fahrt anlegen. Koordinaten kommen aus den (per Karte gesetzten) Hidden-Feldern."""
class Meta:
model = Ride
fields = [
"fahrgast",
"scheduled_at",
"pickup_label",
"pickup_lat",
"pickup_lng",
"dest_label",
"dest_lat",
"dest_lng",
"note",
]
widgets = {
"scheduled_at": forms.DateTimeInput(attrs={"type": "datetime-local"}, format=_DT),
"note": forms.Textarea(attrs={"rows": 2}),
"pickup_lat": forms.HiddenInput(),
"pickup_lng": forms.HiddenInput(),
"dest_lat": forms.HiddenInput(),
"dest_lng": forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["scheduled_at"].input_formats = [_DT]
class AvailabilityForm(forms.ModelForm):
class Meta:
model = Availability
fields = ["start", "end"]
widgets = {
"start": forms.DateTimeInput(attrs={"type": "datetime-local"}, format=_DT),
"end": forms.DateTimeInput(attrs={"type": "datetime-local"}, format=_DT),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name in ("start", "end"):
self.fields[name].input_formats = [_DT]
def clean(self):
cleaned = super().clean()
start, end = cleaned.get("start"), cleaned.get("end")
if start and end and end <= start:
raise forms.ValidationError("Das Ende muss nach dem Beginn liegen.")
return cleaned

View file

View file

View file

@ -0,0 +1,149 @@
"""Befüllt die Datenbank mit Demo-Daten rund um Saarbrücken-Malstatt.
Idempotent: Fahrten/Fahrgäste/Einrichtungen werden vor dem Anlegen geleert,
die Demo-Nutzer per get_or_create aktualisiert.
"""
from datetime import timedelta
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from django.utils import timezone
from rides.models import Availability, Einrichtung, Fahrgast, Profile, Ride, RideStatus, Role
from rides.services import route_geojson
# Reale Orte in/um Saarbrücken (lat, lng)
MALSTATT = (49.2517, 6.9590)
STADEN = (49.2326, 6.9974)
DFG = (49.2236, 6.9637)
SCHLOSS = (49.2310, 6.9960)
BW = (49.2360, 7.0010)
class Command(BaseCommand):
help = "Befüllt die Datenbank mit Demo-Daten (Saarbrücken-Malstatt)."
def handle(self, *args, **options):
Ride.objects.all().delete()
Availability.objects.all().delete()
Fahrgast.objects.all().delete()
Einrichtung.objects.all().delete()
coord = self._user("koordinator", "Mira", "Berg", Role.COORDINATOR, staff=True)
felix = self._user("felix", "Felix", "", Role.PILOT)
anna = self._user("anna", "Anna", "", Role.PILOT)
besteller = self._user("pflege", "Pflegeheim Malstatt", "", Role.REQUESTER)
heim = Einrichtung.objects.create(
name="Seniorenheim Malstatt",
address="Halbergstr. 1, Saarbrücken",
contact="0681 000000",
)
bw = Einrichtung.objects.create(
name="Betreutes Wohnen St. Johann", address="Mainzer Str. 5, Saarbrücken"
)
g1 = Fahrgast.objects.create(
name="Frau Brigitte H.",
einrichtung=heim,
mobility_notes="Rollator, steigt links leichter ein. Mag den Saar-Uferweg.",
)
g2 = Fahrgast.objects.create(
name="Herr Werner K.",
einrichtung=heim,
mobility_notes="Schwerhörig — bitte laut und deutlich sprechen. Decke mitnehmen.",
)
g3 = Fahrgast.objects.create(
name="Frau Anneliese R.",
einrichtung=bw,
mobility_notes="Gut zu Fuß, braucht nur etwas Zeit beim Einsteigen.",
)
now = timezone.now().replace(minute=0, second=0, microsecond=0)
plan = [
dict(
fahrgast=g1,
requested_by=besteller,
when=now + timedelta(days=1, hours=2),
status=RideStatus.REQUESTED,
pickup=("Seniorenheim Malstatt", MALSTATT),
dest=("Staden / Saarufer", STADEN),
),
dict(
fahrgast=g2,
requested_by=coord,
when=now + timedelta(days=1, hours=4),
status=RideStatus.PLANNED,
pickup=("Seniorenheim Malstatt", MALSTATT),
dest=("Deutsch-Französischer Garten", DFG),
),
dict(
fahrgast=g3,
requested_by=coord,
pilot=felix,
when=now + timedelta(days=2, hours=1),
status=RideStatus.CLAIMED,
pickup=("Betreutes Wohnen St. Johann", BW),
dest=("Saarbrücker Schloss", SCHLOSS),
),
dict(
fahrgast=g1,
requested_by=coord,
pilot=anna,
when=now - timedelta(days=2),
status=RideStatus.DONE,
pickup=("Seniorenheim Malstatt", MALSTATT),
dest=("Staden / Saarufer", STADEN),
),
dict(
fahrgast=g2,
requested_by=coord,
pilot=felix,
when=now - timedelta(days=5),
status=RideStatus.DONE,
pickup=("Seniorenheim Malstatt", MALSTATT),
dest=("Saarbrücker Schloss", SCHLOSS),
),
]
for r in plan:
pickup_label, pickup = r["pickup"]
dest_label, dest = r["dest"]
Ride.objects.create(
fahrgast=r["fahrgast"],
requested_by=r["requested_by"],
assigned_pilot=r.get("pilot"),
scheduled_at=r["when"],
status=r["status"],
pickup_label=pickup_label,
pickup_lat=pickup[0],
pickup_lng=pickup[1],
dest_label=dest_label,
dest_lat=dest[0],
dest_lng=dest[1],
route_geojson=route_geojson(pickup, dest),
)
Availability.objects.create(
pilot=felix, start=now + timedelta(days=1), end=now + timedelta(days=1, hours=6)
)
Availability.objects.create(
pilot=felix, start=now + timedelta(days=2), end=now + timedelta(days=2, hours=4)
)
self.stdout.write(
self.style.SUCCESS(f"Demo-Daten erstellt: {len(plan)} Fahrten, 3 Fahrgäste, 4 Nutzer.")
)
self.stdout.write("Login (Passwort 'demo'): koordinator, felix, anna, pflege")
def _user(self, username, first, last, role, staff=False):
user, _ = User.objects.get_or_create(username=username)
user.first_name = first
user.last_name = last
user.is_staff = staff
user.is_superuser = staff
user.set_password("demo")
user.save()
Profile.objects.update_or_create(user=user, defaults={"role": role})
return user

View file

@ -0,0 +1,199 @@
# Generated by Django 6.0.6 on 2026-06-20 18:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Einrichtung",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=120)),
("address", models.CharField(blank=True, max_length=200)),
("contact", models.CharField(blank=True, max_length=120)),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Availability",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("start", models.DateTimeField(verbose_name="Beginn")),
("end", models.DateTimeField(verbose_name="Ende")),
(
"pilot",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="availabilities",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Verfügbarkeit",
"verbose_name_plural": "Verfügbarkeiten",
"ordering": ["start"],
},
),
migrations.CreateModel(
name="Fahrgast",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("name", models.CharField(max_length=120)),
("address", models.CharField(blank=True, max_length=200)),
(
"mobility_notes",
models.TextField(
blank=True,
help_text="Mobilität, Vorlieben, Hinweise für die Fahrt.",
verbose_name="Hinweise",
),
),
(
"einrichtung",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="fahrgaeste",
to="rides.einrichtung",
),
),
],
options={
"verbose_name": "Fahrgast",
"verbose_name_plural": "Fahrgäste",
"ordering": ["name"],
},
),
migrations.CreateModel(
name="Profile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"role",
models.CharField(
choices=[
("coordinator", "Koordination"),
("pilot", "Pilot"),
("requester", "Besteller"),
],
max_length=20,
),
),
("phone", models.CharField(blank=True, max_length=40)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.CreateModel(
name="Ride",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("scheduled_at", models.DateTimeField(verbose_name="Termin")),
(
"status",
models.CharField(
choices=[
("requested", "Angefragt"),
("planned", "Geplant"),
("claimed", "Übernommen"),
("done", "Erledigt"),
("cancelled", "Abgesagt"),
],
default="requested",
max_length=20,
),
),
("pickup_label", models.CharField(max_length=200, verbose_name="Abholort")),
("pickup_lat", models.FloatField()),
("pickup_lng", models.FloatField()),
("dest_label", models.CharField(max_length=200, verbose_name="Ziel")),
("dest_lat", models.FloatField()),
("dest_lng", models.FloatField()),
(
"route_geojson",
models.JSONField(
blank=True,
help_text="Gecachte Route (Start→Ziel) vom Routing-Dienst.",
null=True,
),
),
("note", models.TextField(blank=True, verbose_name="Notiz zur Fahrt")),
(
"assigned_pilot",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="rides",
to=settings.AUTH_USER_MODEL,
),
),
(
"fahrgast",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="rides",
to="rides.fahrgast",
),
),
(
"requested_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="requested_rides",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Fahrt",
"verbose_name_plural": "Fahrten",
"ordering": ["scheduled_at"],
},
),
]

View file

@ -0,0 +1,20 @@
# Generated by Django 6.0.6 on 2026-06-20 19:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("rides", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="fahrgast",
name="address",
),
migrations.RemoveField(
model_name="profile",
name="phone",
),
]

View file

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)

30
rides/services.py Normal file
View file

@ -0,0 +1,30 @@
"""Routing: holt eine Fahrtroute zwischen zwei Punkten.
Bewusst hinter einer einzigen Funktion gekapselt. Für die Demo nutzen wir den
öffentlichen OSRM-Demo-Server (kein API-Key). Für Produktion lässt sich hier
problemlos OpenRouteService oder ein self-hosted OSRM einhängen, ohne dass der
Rest der App es merkt.
"""
from __future__ import annotations
import requests
OSRM_BASE = "https://router.project-osrm.org"
Point = tuple[float, float] # (lat, lng)
def route_geojson(start: Point, end: Point) -> dict | None:
"""Route Start→Ziel als GeoJSON-LineString. None bei Fehler/Offline.
Die Karte fällt in dem Fall auf eine gestrichelte Luftlinie zurück.
"""
s_lat, s_lng = start
e_lat, e_lng = end
url = f"{OSRM_BASE}/route/v1/driving/{s_lng},{s_lat};{e_lng},{e_lat}"
try:
resp = requests.get(url, params={"overview": "full", "geometries": "geojson"}, timeout=8)
resp.raise_for_status()
return resp.json()["routes"][0]["geometry"]
except (requests.RequestException, KeyError, IndexError, ValueError):
return None

View file

@ -0,0 +1,211 @@
/* Radeln ohne Alter bespoke design system.
Warme, ruhige Palette; großzügige Abstände; system-ui-Typografie. */
:root {
--bg: #f6f4ef;
--surface: #ffffff;
--ink: #20262b;
--muted: #6c757d;
--line: #e7e2d9;
--primary: #2f7d57;
--primary-dark: #266547;
--accent: #d98a3d;
--role-coordinator: #d98a3d;
--role-pilot: #2f7d57;
--role-requester: #3a7be8;
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 1px 2px rgba(20, 30, 40, .05), 0 10px 26px rgba(20, 30, 40, .06);
--maxw: 1080px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--font);
font-size: 16px;
line-height: 1.55;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 { line-height: 1.2; letter-spacing: -0.01em; font-weight: 650; }
h1 { font-size: 1.6rem; margin: 0 0 .2em; }
h2 { font-size: 1.2rem; margin: 0 0 .6em; }
h3 { font-size: 1rem; margin: 0 0 .35em; }
a { color: var(--primary-dark); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { width: 100%; max-width: var(--maxw); margin: 0 auto; padding: 0 20px; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.empty { color: var(--muted); padding: 1.2rem 0; }
/* --- Header ------------------------------------------------------------ */
.site-header {
display: flex; align-items: center; gap: 1.5rem;
max-width: var(--maxw); margin: 0 auto; padding: 14px 20px;
}
.brand { display: inline-flex; align-items: center; gap: .55rem; color: var(--ink); font-weight: 700; }
.brand:hover { text-decoration: none; }
.brand-mark { font-size: 1.4rem; }
.brand-text em { font-style: normal; color: var(--primary); }
.site-nav { display: flex; gap: 1.1rem; margin-left: auto; }
.site-nav a { color: var(--ink); font-weight: 500; font-size: .95rem; }
.user-chip { display: inline-flex; align-items: center; gap: .5rem; font-size: .9rem; color: var(--muted); }
.user-name { color: var(--ink); font-weight: 500; }
.role-dot { width: .7rem; height: .7rem; border-radius: 50%; display: inline-block; flex: none; }
.role-coordinator { background: var(--role-coordinator); }
.role-pilot { background: var(--role-pilot); }
.role-requester { background: var(--role-requester); }
.inline { display: inline; }
.link { background: none; border: none; padding: 0; color: var(--muted); cursor: pointer; font: inherit; text-decoration: underline; }
.link:hover { color: var(--ink); }
/* --- Layout ------------------------------------------------------------ */
main.container { padding-top: 8px; padding-bottom: 40px; }
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.block { margin-top: 2rem; }
.back { display: inline-block; color: var(--muted); margin-bottom: .6rem; font-size: .9rem; }
/* --- Buttons ----------------------------------------------------------- */
.btn {
display: inline-flex; align-items: center; gap: .4rem;
font: inherit; font-size: .92rem; font-weight: 550;
padding: .5rem .9rem; border-radius: var(--radius-sm);
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
cursor: pointer; transition: transform .04s ease, background .15s ease, border-color .15s ease;
}
.btn:hover { border-color: #d8d2c6; text-decoration: none; }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn-ghost:hover { background: rgba(0, 0, 0, .04); border-color: transparent; }
/* --- Cards ------------------------------------------------------------- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.1rem 1.2rem; box-shadow: var(--shadow);
}
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
/* --- Badge ------------------------------------------------------------- */
.badge {
display: inline-flex; align-items: center; gap: .45em;
font-size: .76rem; font-weight: 600; padding: .26em .7em; border-radius: 999px;
background: color-mix(in srgb, var(--c) 14%, white);
color: color-mix(in srgb, var(--c) 60%, #20262b);
}
.badge::before { content: ""; width: .55em; height: .55em; border-radius: 50%; background: var(--c); }
/* --- Ride card --------------------------------------------------------- */
.ride-card { display: flex; flex-direction: column; gap: .5rem; }
.ride-head { display: flex; align-items: center; justify-content: space-between; }
.ride-head time { font-size: .82rem; color: var(--muted); }
.ride-title { margin: 0; font-size: 1.05rem; }
.ride-title a { color: var(--ink); }
.ride-route { display: flex; flex-direction: column; gap: .15rem; margin: 0; font-size: .88rem; color: var(--muted); }
.pt { position: relative; padding-left: 1.1rem; }
.pt::before { position: absolute; left: 0; top: 0; }
.pt-from::before { content: "↑"; color: var(--primary); }
.pt-to::before { content: "↓"; color: var(--accent); }
.ride-actions { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: auto; padding-top: .4rem; }
.ride-actions form { margin: 0; }
/* --- Chips / stats ----------------------------------------------------- */
.stat-chips { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1.2rem; }
.chip {
display: inline-flex; align-items: center; gap: .45rem;
background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: .35rem .8rem; font-size: .85rem;
}
.chip strong { font-variant-numeric: tabular-nums; }
.big-number { display: flex; flex-direction: column; align-items: flex-start; }
.big-number strong { font-size: 2.6rem; line-height: 1; color: var(--primary); font-variant-numeric: tabular-nums; }
.table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.table th, .table td { text-align: left; padding: .65rem .9rem; border-bottom: 1px solid var(--line); }
.table thead th { font-size: .8rem; color: var(--muted); font-weight: 600; }
.table tbody tr:last-child td { border-bottom: none; }
/* --- Hero / role picker ------------------------------------------------ */
.hero { max-width: 640px; margin: 3rem auto; text-align: center; }
.hero h1 { font-size: 2rem; }
.lede { font-size: 1.1rem; color: var(--muted); margin: .4rem 0 2rem; }
.role-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .9rem; }
.role-grid form { margin: 0; }
.role-card {
width: 100%; display: flex; flex-direction: column; align-items: center; gap: .3rem;
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
padding: 1.3rem 1rem; cursor: pointer; box-shadow: var(--shadow);
transition: transform .08s ease, border-color .15s ease;
}
.role-card:hover { transform: translateY(-2px); border-color: #d8d2c6; }
.role-card strong { font-size: 1rem; }
.role-card .role-dot { width: .9rem; height: .9rem; margin-bottom: .2rem; }
.hint { color: var(--muted); font-size: .85rem; margin-top: 1.6rem; }
/* --- Detail ------------------------------------------------------------ */
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem; align-items: start; }
.detail-grid .map-card { padding: 0; overflow: hidden; }
.meta { display: grid; grid-template-columns: auto 1fr; gap: .35rem 1rem; margin: 1rem 0; }
.meta dt { color: var(--muted); font-size: .85rem; }
.meta dd { margin: 0; }
.notes { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--line); }
.notes-locked { margin-top: 1rem; }
/* --- Maps -------------------------------------------------------------- */
.detail-map { height: 360px; }
.full-map { height: 540px; border: 1px solid var(--line); border-radius: var(--radius); margin-top: 1rem; }
.form-map { height: 320px; border: 1px solid var(--line); border-radius: var(--radius); margin: .4rem 0 1rem; }
.pin { border-radius: 50%; border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, .4); }
.pin-green { background: #2f8f5b; }
.pin-red { background: #d14b4b; }
.dot-inline { display: inline-block; width: .7rem; height: .7rem; border-radius: 50%; vertical-align: middle; }
/* --- Forms ------------------------------------------------------------- */
.form { display: flex; flex-direction: column; gap: .9rem; max-width: 640px; }
.form label { display: flex; flex-direction: column; gap: .3rem; font-size: .9rem; color: var(--muted); }
.form input, .form select, .form textarea {
font: inherit; color: var(--ink); padding: .55rem .7rem;
border: 1px solid var(--line); border-radius: var(--radius-sm); background: #fff; width: 100%;
}
.form input:focus, .form select:focus, .form textarea:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: .9rem; }
.form-errors, .errorlist { color: #c0392b; font-size: .85rem; margin: 0; }
.avail { margin-top: 2rem; }
.avail-list { list-style: none; padding: 0; margin: 0 0 1rem; display: flex; flex-direction: column; gap: .25rem; }
.avail-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .7rem; }
.avail-form label { font-size: .85rem; color: var(--muted); display: flex; flex-direction: column; gap: .25rem; }
.avail-form input { font: inherit; padding: .45rem .6rem; border: 1px solid var(--line); border-radius: var(--radius-sm); }
/* --- Messages ---------------------------------------------------------- */
.messages { list-style: none; padding: 0; margin: 1rem auto; display: flex; flex-direction: column; gap: .5rem; }
.msg { padding: .6rem .9rem; border-radius: var(--radius-sm); border: 1px solid var(--line); background: var(--surface); }
.msg-success { border-color: color-mix(in srgb, var(--primary) 40%, var(--line)); background: color-mix(in srgb, var(--primary) 8%, white); }
.msg-error { border-color: #e3b4ad; background: #fbeeec; }
/* --- Footer ------------------------------------------------------------ */
.site-footer { padding: 2rem 20px; color: var(--muted); font-size: .85rem; border-top: 1px solid var(--line); margin-top: 2rem; }
/* --- Responsive -------------------------------------------------------- */
@media (max-width: 720px) {
.site-header { flex-wrap: wrap; gap: .8rem; }
.site-nav { order: 3; width: 100%; margin-left: 0; }
.detail-grid { grid-template-columns: 1fr; }
.two-col { grid-template-columns: 1fr; }
}

View file

@ -0,0 +1,42 @@
<article class="card ride-card">
<div class="ride-head">
<span class="badge" style="--c: {{ ride.status_color }}">{{ ride.get_status_display }}</span>
<time datetime="{{ ride.scheduled_at|date:'c' }}">{{ ride.scheduled_at|date:"D, d.m. H:i" }}</time>
</div>
<h3 class="ride-title"><a href="{% url 'ride_detail' ride.pk %}">{{ ride.fahrgast.name }}</a></h3>
<p class="ride-route">
<span class="pt pt-from">{{ ride.pickup_label }}</span>
<span class="pt pt-to">{{ ride.dest_label }}</span>
</p>
{% if ride.assigned_pilot %}
<p class="muted small">Pilot: {{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}</p>
{% endif %}
<div class="ride-actions">
{% if can_claim and ride.can_be_claimed %}
<form hx-post="{% url 'ride_claim' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_claim' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn btn-primary">Übernehmen</button>
</form>
{% endif %}
{% if can_finish and ride.can_be_finished %}
<form hx-post="{% url 'ride_done' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_done' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn">Erledigt</button>
</form>
{% endif %}
{% if can_cancel and ride.can_be_cancelled %}
<form hx-post="{% url 'ride_cancel' ride.pk %}" hx-target="closest .ride-card" hx-swap="outerHTML"
method="post" action="{% url 'ride_cancel' ride.pk %}">{% csrf_token %}
<input type="hidden" name="next" value="{{ request.path }}">
<button class="btn btn-ghost">Absagen</button>
</form>
{% endif %}
<a class="btn btn-ghost" href="{% url 'ride_detail' ride.pk %}">Details</a>
</div>
</article>

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Einzugsgebiet · Radeln ohne Alter{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<h1>Unser Einzugsgebiet</h1>
<p class="lede">Wir holen Sie in Malstatt, St. Johann und Alt-Saarbrücken ab — kostenlos und ehrenamtlich.
„Für das Recht auf Wind in den Haaren."</p>
<div id="map" class="full-map"></div>
<p class="muted small">Diese Seite ist öffentlich und enthält keine personenbezogenen Daten.</p>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map").setView([49.238, 6.980], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
// Grobes Einzugsgebiet (Malstatt / St. Johann / Alt-Saarbrücken)
const area = [
[49.262, 6.945], [49.258, 6.985], [49.240, 7.012],
[49.222, 6.995], [49.218, 6.958], [49.235, 6.938],
];
L.polygon(area, { color: "#2f7d57", weight: 2, fillColor: "#2f7d57", fillOpacity: 0.12 }).addTo(map);
</script>
{% endblock %}

View file

@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Übersicht · Radeln ohne Alter{% endblock %}
{% block content %}
{% if role == 'coordinator' %}
<div class="section-head">
<h1>Übersicht</h1>
<a class="btn btn-primary" href="{% url 'ride_create' %}">+ Fahrt anlegen</a>
</div>
<div class="stat-chips">
{% for s in status_counts %}
<span class="chip"><span class="role-dot" style="background:{{ s.color }}"></span>{{ s.label }} <strong>{{ s.n }}</strong></span>
{% endfor %}
</div>
<div class="card-grid">
{% for ride in rides %}
{% include "rides/_ride_card.html" with ride=ride can_finish=True can_cancel=True %}
{% empty %}
<p class="empty">Keine anstehenden Fahrten.</p>
{% endfor %}
</div>
{% elif role == 'pilot' %}
<h1>Hallo {{ user.first_name|default:user.username }}</h1>
<section class="block">
<h2>Offene Fahrten</h2>
<div class="card-grid">
{% for ride in open_rides %}
{% include "rides/_ride_card.html" with ride=ride can_claim=True %}
{% empty %}
<p class="empty">Gerade keine offenen Fahrten.</p>
{% endfor %}
</div>
</section>
<section class="block">
<h2>Meine Fahrten</h2>
<div class="card-grid">
{% for ride in my_rides %}
{% include "rides/_ride_card.html" with ride=ride can_finish=True %}
{% empty %}
<p class="empty">Noch keine Fahrt übernommen.</p>
{% endfor %}
</div>
</section>
<section class="block card avail">
<h2>Meine Verfügbarkeit</h2>
<ul class="avail-list">
{% for a in availabilities %}
<li>{{ a.start|date:"D, d.m. H:i" }} {{ a.end|date:"H:i" }}</li>
{% empty %}
<li class="muted">Noch nichts eingetragen.</li>
{% endfor %}
</ul>
<form method="post" action="{% url 'availability_add' %}" class="avail-form">{% csrf_token %}
<label>Von {{ availability_form.start }}</label>
<label>Bis {{ availability_form.end }}</label>
<button class="btn">Hinzufügen</button>
</form>
</section>
{% else %}
<div class="section-head">
<h1>Meine Anfragen</h1>
<a class="btn btn-primary" href="{% url 'ride_create' %}">+ Fahrt anfragen</a>
</div>
<div class="card-grid">
{% for ride in my_requests %}
{% include "rides/_ride_card.html" with ride=ride %}
{% empty %}
<p class="empty">Noch keine Anfragen gestellt.</p>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<section class="hero">
<h1>Fahrten koordinieren — ohne WhatsApp-Chaos</h1>
<p class="lede">Verfügbarkeiten, Anfragen und Touren an einem Ort. Wähle eine Rolle, um die Demo auszuprobieren.</p>
<div class="role-grid">
{% for u in demo_users %}
<form method="post" action="{% url 'demo_login' u.id %}">{% csrf_token %}
<button class="role-card">
<span class="role-dot role-{{ u.profile.role }}"></span>
<strong>{{ u.get_full_name|default:u.username }}</strong>
<span class="muted">{{ u.profile.get_role_display }}</span>
</button>
</form>
{% empty %}
<p class="empty">Noch keine Demo-Daten. Bitte zuerst <code>python manage.py seed_demo</code> ausführen.</p>
{% endfor %}
</div>
<p class="hint">Demo-Login ohne Passwort — nur in der lokalen Vorschau aktiv.</p>
</section>
{% endblock %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Karte · Radeln ohne Alter{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<h1>Fahrten-Karte</h1>
<p class="muted">Abholorte der anstehenden und erledigten Fahrten. Farbe = Status.</p>
<div id="map" class="full-map"></div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map").setView([49.240, 6.985], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
fetch("{% url 'rides_geojson' %}")
.then((r) => r.json())
.then((data) => {
const layer = L.geoJSON(data, {
pointToLayer: (f, latlng) =>
L.circleMarker(latlng, {
radius: 9, weight: 2, color: "#fff",
fillColor: f.properties.color, fillOpacity: 0.95,
}),
onEachFeature: (f, l) =>
l.bindPopup(
`<strong>${f.properties.label}</strong><br>${f.properties.status} · ${f.properties.when}` +
`<br><a href="${f.properties.url}">Details →</a>`
),
}).addTo(map);
if (data.features.length) map.fitBounds(layer.getBounds().pad(0.2));
});
</script>
{% endblock %}

View file

@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}{{ ride.fahrgast.name }} · Fahrt{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<div class="detail-grid">
<div class="card">
<span class="badge" style="--c: {{ ride.status_color }}">{{ ride.get_status_display }}</span>
<h1>{{ ride.fahrgast.name }}</h1>
<dl class="meta">
<dt>Wann</dt><dd>{{ ride.scheduled_at|date:"l, d. F Y, H:i" }} Uhr</dd>
<dt>Abholung</dt><dd>{{ ride.pickup_label }}</dd>
<dt>Ziel</dt><dd>{{ ride.dest_label }}</dd>
{% if ride.assigned_pilot %}<dt>Pilot</dt><dd>{{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}</dd>{% endif %}
{% if ride.fahrgast.einrichtung %}<dt>Einrichtung</dt><dd>{{ ride.fahrgast.einrichtung.name }}</dd>{% endif %}
</dl>
{% if show_notes %}
{% if ride.fahrgast.mobility_notes %}
<div class="notes"><h3>Hinweise zum Fahrgast</h3><p>{{ ride.fahrgast.mobility_notes }}</p></div>
{% endif %}
{% if ride.note %}
<div class="notes"><h3>Notiz zur Fahrt</h3><p>{{ ride.note }}</p></div>
{% endif %}
{% else %}
<p class="muted small notes-locked">🔒 Pflege-Hinweise sind nur für die zugeteilte Pilot:in und die Koordination sichtbar.</p>
{% endif %}
</div>
<div class="card map-card">
<div id="map" class="detail-map"></div>
</div>
</div>
{{ ride.route_geojson|json_script:"route-data" }}
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const pickup = [{{ ride.pickup_lat }}, {{ ride.pickup_lng }}];
const dest = [{{ ride.dest_lat }}, {{ ride.dest_lng }}];
const route = JSON.parse(document.getElementById("route-data").textContent);
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
L.marker(pickup).addTo(map).bindPopup("Abholung");
L.marker(dest).addTo(map).bindPopup("Ziel");
let line;
if (route) {
line = L.geoJSON(route, { style: { color: "#2f7d57", weight: 5 } }).addTo(map);
} else {
line = L.polyline([pickup, dest], { color: "#2f7d57", weight: 4, dashArray: "6 8" }).addTo(map);
}
map.fitBounds(line.getBounds().pad(0.3));
</script>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Fahrt anlegen{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<h1>Fahrt anlegen</h1>
<form method="post" class="form card">{% csrf_token %}
{% if form.non_field_errors %}<div class="form-errors">{{ form.non_field_errors }}</div>{% endif %}
<label>Fahrgast {{ form.fahrgast }}{{ form.fahrgast.errors }}</label>
<label>Termin {{ form.scheduled_at }}{{ form.scheduled_at.errors }}</label>
<div class="two-col">
<label>Abholort {{ form.pickup_label }}{{ form.pickup_label.errors }}</label>
<label>Ziel {{ form.dest_label }}{{ form.dest_label.errors }}</label>
</div>
<label>Notiz {{ form.note }}</label>
{{ form.pickup_lat }}{{ form.pickup_lng }}{{ form.dest_lat }}{{ form.dest_lng }}
<p class="muted small">Marker auf der Karte ziehen: <span class="dot-inline" style="background:#2f8f5b"></span> Abholung,
<span class="dot-inline" style="background:#d14b4b"></span> Ziel.</p>
<div id="pickmap" class="form-map"></div>
<button class="btn btn-primary">Fahrt speichern</button>
</form>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const $ = (id) => document.getElementById(id);
// Voreinstellung: Malstatt (Abholung) → Staden (Ziel)
const start = { lat: 49.2517, lng: 6.9590 };
const end = { lat: 49.2326, lng: 6.9974 };
const sync = (point, latId, lngId) => { $(latId).value = point.lat.toFixed(6); $(lngId).value = point.lng.toFixed(6); };
sync(start, "id_pickup_lat", "id_pickup_lng");
sync(end, "id_dest_lat", "id_dest_lng");
const map = L.map("pickmap").setView([49.240, 6.978], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19, attribution: "© OpenStreetMap"
}).addTo(map);
const greenIcon = L.divIcon({ className: "pin pin-green", html: "", iconSize: [18, 18] });
const redIcon = L.divIcon({ className: "pin pin-red", html: "", iconSize: [18, 18] });
const a = L.marker([start.lat, start.lng], { draggable: true, icon: greenIcon }).addTo(map);
const b = L.marker([end.lat, end.lng], { draggable: true, icon: redIcon }).addTo(map);
a.on("dragend", () => sync(a.getLatLng(), "id_pickup_lat", "id_pickup_lng"));
b.on("dragend", () => sync(b.getLatLng(), "id_dest_lat", "id_dest_lng"));
</script>
{% endblock %}

View file

@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Statistik · Radeln ohne Alter{% endblock %}
{% block content %}
<a class="back" href="{% url 'dashboard' %}">← Übersicht</a>
<h1>Statistik</h1>
<div class="big-number card">
<strong>{{ total_done }}</strong>
<span class="muted">erledigte Fahrten insgesamt</span>
</div>
<section class="block">
<h2>Nach Status</h2>
<div class="stat-chips">
{% for s in status_counts %}
<span class="chip"><span class="role-dot" style="background:{{ s.color }}"></span>{{ s.label }} <strong>{{ s.n }}</strong></span>
{% endfor %}
</div>
</section>
<section class="block">
<h2>Erledigte Fahrten je Pilot:in</h2>
<table class="table">
<thead><tr><th>Pilot:in</th><th>Fahrten</th></tr></thead>
<tbody>
{% for row in per_pilot %}
<tr>
<td>{{ row.assigned_pilot__first_name|default:row.assigned_pilot__username|default:"—" }}</td>
<td>{{ row.n }}</td>
</tr>
{% empty %}
<tr><td colspan="2" class="muted">Noch keine erledigten Fahrten.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

0
rides/tests/__init__.py Normal file
View file

View file

@ -0,0 +1,53 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.utils import timezone
from rides.models import Fahrgast, Profile, Ride, RideStatus, Role
class RideLifecycleTests(TestCase):
def setUp(self):
self.pilot = User.objects.create_user("pilot")
Profile.objects.create(user=self.pilot, role=Role.PILOT)
self.fahrgast = Fahrgast.objects.create(name="Frau M.")
self.ride = Ride.objects.create(
fahrgast=self.fahrgast,
scheduled_at=timezone.now(),
pickup_label="A",
pickup_lat=49.25,
pickup_lng=6.96,
dest_label="B",
dest_lat=49.23,
dest_lng=6.99,
)
def test_claim_sets_pilot_and_status(self):
self.ride.claim(self.pilot)
self.assertEqual(self.ride.status, RideStatus.CLAIMED)
self.assertEqual(self.ride.assigned_pilot, self.pilot)
def test_cannot_finish_unclaimed_ride(self):
with self.assertRaises(ValueError):
self.ride.mark_done()
def test_claim_then_done(self):
self.ride.claim(self.pilot)
self.ride.mark_done()
self.assertEqual(self.ride.status, RideStatus.DONE)
def test_cannot_claim_done_ride(self):
self.ride.claim(self.pilot)
self.ride.mark_done()
with self.assertRaises(ValueError):
self.ride.claim(self.pilot)
def test_cannot_cancel_done_ride(self):
self.ride.claim(self.pilot)
self.ride.mark_done()
with self.assertRaises(ValueError):
self.ride.cancel()
def test_open_for_pilots_excludes_claimed(self):
self.assertIn(self.ride, Ride.objects.open_for_pilots())
self.ride.claim(self.pilot)
self.assertNotIn(self.ride, Ride.objects.open_for_pilots())

View file

@ -0,0 +1,65 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rides.models import Fahrgast, Profile, Ride, RideStatus, Role
SECRET = "PFLEGEHINWEIS-GEHEIM-XYZ"
class VisibilityTests(TestCase):
def setUp(self):
self.pilot_a = User.objects.create_user("pilot_a", first_name="Anton")
self.pilot_b = User.objects.create_user("pilot_b", first_name="Berta")
Profile.objects.create(user=self.pilot_a, role=Role.PILOT)
Profile.objects.create(user=self.pilot_b, role=Role.PILOT)
self.fahrgast = Fahrgast.objects.create(name="Herr K.", mobility_notes=SECRET)
self.ride = Ride.objects.create(
fahrgast=self.fahrgast,
scheduled_at=timezone.now(),
assigned_pilot=self.pilot_a,
status=RideStatus.CLAIMED,
pickup_label="A",
pickup_lat=49.25,
pickup_lng=6.96,
dest_label="B",
dest_lat=49.23,
dest_lng=6.99,
)
def test_foreign_pilot_cannot_view_assigned_ride(self):
self.client.force_login(self.pilot_b)
resp = self.client.get(reverse("ride_detail", args=[self.ride.pk]))
self.assertEqual(resp.status_code, 403)
def test_foreign_pilot_never_sees_notes(self):
self.client.force_login(self.pilot_b)
resp = self.client.get(reverse("ride_detail", args=[self.ride.pk]))
self.assertNotContains(resp, SECRET, status_code=403)
def test_assigned_pilot_sees_notes(self):
self.client.force_login(self.pilot_a)
resp = self.client.get(reverse("ride_detail", args=[self.ride.pk]))
self.assertContains(resp, SECRET)
def test_open_ride_visible_to_any_pilot(self):
self.ride.assigned_pilot = None
self.ride.status = RideStatus.REQUESTED
self.ride.save()
self.client.force_login(self.pilot_b)
resp = self.client.get(reverse("ride_detail", args=[self.ride.pk]))
self.assertEqual(resp.status_code, 200)
def test_geojson_respects_visibility(self):
# Eine fremde, vergebene Fahrt darf NICHT in der Karten-API eines
# anderen Piloten auftauchen (HIGH-Finding aus dem Code-Review).
self.ride.pickup_label = "GEHEIM-ABHOLORT"
self.ride.save()
self.client.force_login(self.pilot_b)
resp = self.client.get(reverse("rides_geojson"))
self.assertNotContains(resp, "GEHEIM-ABHOLORT")
# die zugeteilte Pilot:in sieht sie sehr wohl
self.client.force_login(self.pilot_a)
resp = self.client.get(reverse("rides_geojson"))
self.assertContains(resp, "GEHEIM-ABHOLORT")

20
rides/urls.py Normal file
View file

@ -0,0 +1,20 @@
from django.urls import path
from . import views
urlpatterns = [
path("", views.home, name="home"),
path("demo-login/<int:user_id>/", views.demo_login, name="demo_login"),
path("logout/", views.logout_view, name="logout"),
path("dashboard/", views.dashboard, name="dashboard"),
path("fahrten/neu/", views.ride_create, name="ride_create"),
path("fahrten/<int:pk>/", views.ride_detail, name="ride_detail"),
path("fahrten/<int:pk>/uebernehmen/", views.ride_claim, name="ride_claim"),
path("fahrten/<int:pk>/erledigt/", views.ride_done, name="ride_done"),
path("fahrten/<int:pk>/absagen/", views.ride_cancel, name="ride_cancel"),
path("verfuegbarkeit/neu/", views.availability_add, name="availability_add"),
path("karte/", views.map_view, name="map"),
path("karte/fahrten.geojson", views.rides_geojson, name="rides_geojson"),
path("einzugsgebiet/", views.coverage, name="coverage"),
path("statistik/", views.stats, name="stats"),
]

272
rides/views.py Normal file
View file

@ -0,0 +1,272 @@
"""Views: dünn gehalten, die Fachlogik liegt im Modell.
Aktionen (übernehmen/erledigt/absagen) liefern bei HTMX-Anfragen nur die
aktualisierte Fahrt-Karte zurück (Inline-Update ohne Reload), sonst einen
klassischen Redirect funktioniert also auch ohne JavaScript.
"""
from __future__ import annotations
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.db.models import Count
from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from .forms import AvailabilityForm, RideForm
from .models import STATUS_COLORS, Ride, RideStatus
def _is_htmx(request) -> bool:
return request.headers.get("HX-Request") == "true"
def _status_counts() -> list[dict]:
counts = {
row["status"]: row["n"] for row in Ride.objects.values("status").annotate(n=Count("id"))
}
return [
{"label": s.label, "n": counts.get(s.value, 0), "color": STATUS_COLORS[s.value]}
for s in RideStatus
]
# --- Einstieg / Demo-Login -----------------------------------------------
def home(request):
if request.user.is_authenticated:
return redirect("dashboard")
demo_users = (
User.objects.filter(profile__isnull=False)
.select_related("profile")
.order_by("profile__role")
)
return render(request, "rides/home.html", {"demo_users": demo_users})
@require_POST
def demo_login(request, user_id: int):
"""Schneller Rollenwechsel für die Demo — nur bei DEBUG aktiv."""
if not settings.DEMO_MODE:
return HttpResponseForbidden("Demo-Login ist deaktiviert.")
user = get_object_or_404(User, pk=user_id)
login(request, user)
return redirect("dashboard")
def logout_view(request):
logout(request)
return redirect("home")
# --- Übersicht -----------------------------------------------------------
@login_required
def dashboard(request):
profile = getattr(request.user, "profile", None)
role = profile.role if profile else None
ctx: dict = {"role": role}
if profile and profile.is_coordinator:
ctx["status_counts"] = _status_counts()
ctx["rides"] = Ride.objects.upcoming().select_related("fahrgast", "assigned_pilot")
elif profile and profile.is_pilot:
ctx["open_rides"] = Ride.objects.open_for_pilots().select_related("fahrgast")
ctx["my_rides"] = (
Ride.objects.filter(assigned_pilot=request.user)
.exclude(status=RideStatus.CANCELLED)
.select_related("fahrgast")
)
ctx["availabilities"] = request.user.availabilities.filter(end__gte=timezone.now())
ctx["availability_form"] = AvailabilityForm()
else:
ctx["my_requests"] = Ride.objects.filter(requested_by=request.user).select_related(
"fahrgast"
)
return render(request, "rides/dashboard.html", ctx)
# --- Fahrten -------------------------------------------------------------
@login_required
def ride_detail(request, pk: int):
ride = get_object_or_404(
Ride.objects.select_related("fahrgast__einrichtung", "assigned_pilot"),
pk=pk,
)
if not ride.visible_to(request.user):
return HttpResponseForbidden("Diese Fahrt ist für dich nicht sichtbar.")
return render(
request,
"rides/ride_detail.html",
{"ride": ride, "show_notes": ride.notes_visible_to(request.user)},
)
@login_required
def ride_create(request):
profile = getattr(request.user, "profile", None)
if not (profile and (profile.is_coordinator or profile.is_requester)):
return HttpResponseForbidden("Nur Koordination und Besteller:innen dürfen Fahrten anlegen.")
if request.method == "POST":
form = RideForm(request.POST)
if form.is_valid():
ride = form.save(commit=False)
ride.requested_by = request.user
ride.status = RideStatus.PLANNED if profile.is_coordinator else RideStatus.REQUESTED
from .services import route_geojson
ride.route_geojson = route_geojson(
(ride.pickup_lat, ride.pickup_lng), (ride.dest_lat, ride.dest_lng)
)
ride.save()
messages.success(request, "Fahrt angelegt.")
return redirect("ride_detail", pk=ride.pk)
else:
form = RideForm()
return render(request, "rides/ride_form.html", {"form": form})
def _card_response(request, ride, **flags):
"""Aktualisierte Fahrt-Karte (für HTMX-Inline-Update)."""
return render(request, "rides/_ride_card.html", {"ride": ride, **flags})
@require_POST
@login_required
def ride_claim(request, pk: int):
ride = get_object_or_404(Ride, pk=pk)
profile = getattr(request.user, "profile", None)
if not (profile and profile.is_pilot):
return HttpResponseForbidden("Nur Pilot:innen können Fahrten übernehmen.")
try:
ride.claim(request.user)
except ValueError as exc:
messages.error(request, str(exc))
return redirect(request.POST.get("next") or "dashboard")
if _is_htmx(request):
return _card_response(request, ride, can_finish=True)
messages.success(request, "Fahrt übernommen.")
return redirect(request.POST.get("next") or "dashboard")
@require_POST
@login_required
def ride_done(request, pk: int):
ride = get_object_or_404(Ride, pk=pk)
profile = getattr(request.user, "profile", None)
allowed = profile and (profile.is_coordinator or ride.assigned_pilot_id == request.user.id)
if not allowed:
return HttpResponseForbidden()
try:
ride.mark_done()
except ValueError as exc:
messages.error(request, str(exc))
return redirect(request.POST.get("next") or "dashboard")
if _is_htmx(request):
return _card_response(request, ride)
messages.success(request, "Fahrt als erledigt markiert.")
return redirect(request.POST.get("next") or "dashboard")
@require_POST
@login_required
def ride_cancel(request, pk: int):
ride = get_object_or_404(Ride, pk=pk)
profile = getattr(request.user, "profile", None)
if not (profile and profile.is_coordinator):
return HttpResponseForbidden()
try:
ride.cancel()
except ValueError as exc:
messages.error(request, str(exc))
return redirect(request.POST.get("next") or "dashboard")
if _is_htmx(request):
return _card_response(request, ride)
messages.success(request, "Fahrt abgesagt.")
return redirect(request.POST.get("next") or "dashboard")
# --- Verfügbarkeit -------------------------------------------------------
@require_POST
@login_required
def availability_add(request):
profile = getattr(request.user, "profile", None)
if not (profile and profile.is_pilot):
return HttpResponseForbidden()
form = AvailabilityForm(request.POST)
if form.is_valid():
av = form.save(commit=False)
av.pilot = request.user
av.save()
messages.success(request, "Verfügbarkeit gespeichert.")
else:
messages.error(request, "Bitte Beginn und Ende prüfen.")
return redirect("dashboard")
# --- Karte / öffentlich --------------------------------------------------
@login_required
def map_view(request):
return render(request, "rides/map.html")
@login_required
def rides_geojson(request):
features = [
{
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [ride.pickup_lng, ride.pickup_lat]},
"properties": {
"label": ride.pickup_label,
"status": ride.get_status_display(),
"color": ride.status_color,
"when": timezone.localtime(ride.scheduled_at).strftime("%d.%m. %H:%M"),
"url": reverse("ride_detail", args=[ride.pk]),
},
}
for ride in Ride.objects.visible_to(request.user)
]
return JsonResponse({"type": "FeatureCollection", "features": features})
def coverage(request):
"""Öffentliche Einzugsgebiet-Karte — keine Personendaten."""
return render(request, "rides/coverage.html")
@login_required
def stats(request):
profile = getattr(request.user, "profile", None)
if not (profile and profile.is_coordinator):
return HttpResponseForbidden()
done = Ride.objects.filter(status=RideStatus.DONE)
per_pilot = (
done.values("assigned_pilot__first_name", "assigned_pilot__username")
.annotate(n=Count("id"))
.order_by("-n")
)
return render(
request,
"rides/stats.html",
{
"total_done": done.count(),
"per_pilot": per_pilot,
"status_counts": _status_counts(),
},
)