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
0
rides/__init__.py
Normal file
0
rides/__init__.py
Normal file
35
rides/admin.py
Normal file
35
rides/admin.py
Normal 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
7
rides/apps.py
Normal 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"
|
||||
5
rides/context_processors.py
Normal file
5
rides/context_processors.py
Normal 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
57
rides/forms.py
Normal 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
|
||||
0
rides/management/__init__.py
Normal file
0
rides/management/__init__.py
Normal file
0
rides/management/commands/__init__.py
Normal file
0
rides/management/commands/__init__.py
Normal file
149
rides/management/commands/seed_demo.py
Normal file
149
rides/management/commands/seed_demo.py
Normal 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
|
||||
199
rides/migrations/0001_initial.py
Normal file
199
rides/migrations/0001_initial.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
),
|
||||
]
|
||||
0
rides/migrations/__init__.py
Normal file
0
rides/migrations/__init__.py
Normal file
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)
|
||||
30
rides/services.py
Normal file
30
rides/services.py
Normal 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
|
||||
211
rides/static/rides/css/app.css
Normal file
211
rides/static/rides/css/app.css
Normal 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; }
|
||||
}
|
||||
42
rides/templates/rides/_ride_card.html
Normal file
42
rides/templates/rides/_ride_card.html
Normal 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>
|
||||
29
rides/templates/rides/coverage.html
Normal file
29
rides/templates/rides/coverage.html
Normal 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 %}
|
||||
78
rides/templates/rides/dashboard.html
Normal file
78
rides/templates/rides/dashboard.html
Normal 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 %}
|
||||
23
rides/templates/rides/home.html
Normal file
23
rides/templates/rides/home.html
Normal 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 %}
|
||||
38
rides/templates/rides/map.html
Normal file
38
rides/templates/rides/map.html
Normal 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 %}
|
||||
64
rides/templates/rides/ride_detail.html
Normal file
64
rides/templates/rides/ride_detail.html
Normal 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 %}
|
||||
58
rides/templates/rides/ride_form.html
Normal file
58
rides/templates/rides/ride_form.html
Normal 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 %}
|
||||
37
rides/templates/rides/stats.html
Normal file
37
rides/templates/rides/stats.html
Normal 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
0
rides/tests/__init__.py
Normal file
53
rides/tests/test_models.py
Normal file
53
rides/tests/test_models.py
Normal 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())
|
||||
65
rides/tests/test_permissions.py
Normal file
65
rides/tests/test_permissions.py
Normal 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
20
rides/urls.py
Normal 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
272
rides/views.py
Normal 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(),
|
||||
},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue