commit 5d967c68b5a81ac2291e3a9398997dc4ee0beb83 Author: steffen1971 Date: Sat Jun 20 21:20:17 2026 +0200 Add Radeln ohne Alter ride-coordination demo (Django + HTMX + Leaflet) Co-Authored-By: Claude Opus 4.8 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ae3050 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +db.sqlite3 +.ruff_cache/ +staticfiles/ +.git/ +.idea/ +.vscode/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4744088 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.venv/ +__pycache__/ +*.pyc +db.sqlite3 +.ruff_cache/ +/staticfiles/ +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..233965c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +# Demo: bei jedem Start migrieren + Demo-Daten frisch seeden (sauberer Stand), +# dann via gunicorn ausliefern. SQLite liegt im Container (bewusst flüchtig). +CMD ["sh", "-c", "python manage.py migrate --noinput && python manage.py seed_demo && gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c798b15 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# Radeln ohne Alter — Koordinations-Demo + +Schlanke Web-App, die die Rikscha-Fahrten des Projekts *Radeln ohne Alter* +(Saarbrücken-Malstatt) koordiniert — als Ablösung der bisherigen WhatsApp-Gruppe. +Dieser Stand ist eine **Demo** zum Draufschauen und Iterieren, kein Produktivsystem. + +## Was die Demo zeigt + +- **Fahrt-Lebenszyklus** als Herzstück: `Angefragt → Geplant → Übernommen → Erledigt` + (löst die WhatsApp-Schwächen Überblick / Historie / Auswertung auf einmal). +- **Rollen** mit unterschiedlicher Sicht: Koordination, Pilot, Besteller. +- **Karte** (Leaflet + OpenStreetMap): Abholorte als Pins, je Fahrt eine berechnete + Route (öffentlicher OSRM-Demo-Server). +- **Rollen-Sichtbarkeit**: Pflege-Hinweise sieht nur die zugeteilte Pilot:in bzw. die Koordination. + +## Stack + +Django 6 · server-rendered Templates + HTMX (für Inline-Aktionen) · Leaflet · SQLite · `ruff`. +Bewusst „langweilig" und abhängigkeitsarm. + +## Lokal starten + +```bash +python -m venv .venv +.venv/Scripts/python -m pip install -r requirements.txt # Windows +python manage.py migrate +python manage.py seed_demo +python manage.py runserver +``` + +Dann http://127.0.0.1:8000/ öffnen und eine Rolle per Demo-Button wählen +(Login ohne Passwort, nur bei `DEBUG`). Klassischer Login: Nutzer `koordinator`, +`felix`, `anna`, `pflege`, Passwort `demo`. + +## Code-Qualität + +```bash +ruff check . +ruff format . +python manage.py test +``` + +## Struktur + +- `config/` — Projekt (Settings, URLs). +- `rides/` — die ganze Fachlogik: + - `models.py` — Datenmodell + Status-Übergänge + Sichtbarkeitsregeln. + - `services.py` — Routing (austauschbar: OSRM-Demo → später OpenRouteService/self-hosted). + - `views.py`, `urls.py`, `forms.py`, `templates/`, `static/`. + - `management/commands/seed_demo.py` — Demo-Daten. + - `tests/` — Status-Lebenszyklus + Rollen-Sichtbarkeit. diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..856079b --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..76de0ef --- /dev/null +++ b/config/settings.py @@ -0,0 +1,95 @@ +"""Django-Settings für die 'Radeln ohne Alter'-Demo. + +Bewusst schlank: SQLite, eine Handvoll Abhängigkeiten. Sicherheitsrelevante +Schalter (DEBUG, SECRET_KEY, ALLOWED_HOSTS) sind per Umgebungsvariable +übersteuerbar, damit dieselbe Codebasis lokal (DEBUG an) und öffentlich +(DEBUG aus) laufen kann. +""" + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def _env_bool(name: str, default: bool) -> bool: + return os.environ.get(name, "1" if default else "0") == "1" + + +SECRET_KEY = os.environ.get("SECRET_KEY", "django-insecure-demo-key-not-for-production") +DEBUG = _env_bool("DEBUG", True) +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split(",") + +# Demo-Login (Rollen-Buttons) bewusst von DEBUG ENTKOPPELT: so kann die +# öffentliche Demo mit DEBUG=False (sicher, keine Tracebacks) laufen und die +# Rollen-Buttons trotzdem aktiv lassen. +DEMO_MODE = _env_bool("DEMO_MODE", True) + +CSRF_TRUSTED_ORIGINS = [o for o in os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") if o] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rides", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "rides.context_processors.user_profile", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [] # Demo: keine Passwort-Policy + +LANGUAGE_CODE = "de" +TIME_ZONE = "Europe/Berlin" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage"}, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LOGIN_URL = "home" diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..1978567 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("rides.urls")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..8509335 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..8cdbdd7 --- /dev/null +++ b/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..92e0661 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "radeln" +version = "0.1.0" +description = "Radeln ohne Alter — Fahrten-Koordination (Demo)" +requires-python = ">=3.12" + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +# pycodestyle, pyflakes, isort, pyupgrade, bugbear, flake8-django +select = ["E", "F", "I", "UP", "B", "DJ"] + +[tool.ruff.lint.per-file-ignores] +"*/migrations/*" = ["E501"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..54f14c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Django>=6.0,<6.1 +requests>=2.31 +whitenoise>=6.6 +gunicorn>=23.0 diff --git a/rides/__init__.py b/rides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rides/admin.py b/rides/admin.py new file mode 100644 index 0000000..5ffacc1 --- /dev/null +++ b/rides/admin.py @@ -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"] diff --git a/rides/apps.py b/rides/apps.py new file mode 100644 index 0000000..f50862d --- /dev/null +++ b/rides/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RidesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "rides" + verbose_name = "Fahrten" diff --git a/rides/context_processors.py b/rides/context_processors.py new file mode 100644 index 0000000..e065c76 --- /dev/null +++ b/rides/context_processors.py @@ -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} diff --git a/rides/forms.py b/rides/forms.py new file mode 100644 index 0000000..def5c5f --- /dev/null +++ b/rides/forms.py @@ -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 diff --git a/rides/management/__init__.py b/rides/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rides/management/commands/__init__.py b/rides/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rides/management/commands/seed_demo.py b/rides/management/commands/seed_demo.py new file mode 100644 index 0000000..8378a47 --- /dev/null +++ b/rides/management/commands/seed_demo.py @@ -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 diff --git a/rides/migrations/0001_initial.py b/rides/migrations/0001_initial.py new file mode 100644 index 0000000..0f78c3b --- /dev/null +++ b/rides/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/rides/migrations/0002_remove_fahrgast_address_remove_profile_phone.py b/rides/migrations/0002_remove_fahrgast_address_remove_profile_phone.py new file mode 100644 index 0000000..a829826 --- /dev/null +++ b/rides/migrations/0002_remove_fahrgast_address_remove_profile_phone.py @@ -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", + ), + ] diff --git a/rides/migrations/__init__.py b/rides/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rides/models.py b/rides/models.py new file mode 100644 index 0000000..a00f8b4 --- /dev/null +++ b/rides/models.py @@ -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) diff --git a/rides/services.py b/rides/services.py new file mode 100644 index 0000000..42aacd7 --- /dev/null +++ b/rides/services.py @@ -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 diff --git a/rides/static/rides/css/app.css b/rides/static/rides/css/app.css new file mode 100644 index 0000000..d7cf604 --- /dev/null +++ b/rides/static/rides/css/app.css @@ -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; } +} diff --git a/rides/templates/rides/_ride_card.html b/rides/templates/rides/_ride_card.html new file mode 100644 index 0000000..ecec01c --- /dev/null +++ b/rides/templates/rides/_ride_card.html @@ -0,0 +1,42 @@ +
+
+ {{ ride.get_status_display }} + +
+

{{ ride.fahrgast.name }}

+

+ {{ ride.pickup_label }} + {{ ride.dest_label }} +

+ {% if ride.assigned_pilot %} +

Pilot: {{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}

+ {% endif %} + +
+ {% if can_claim and ride.can_be_claimed %} +
{% csrf_token %} + + +
+ {% endif %} + + {% if can_finish and ride.can_be_finished %} +
{% csrf_token %} + + +
+ {% endif %} + + {% if can_cancel and ride.can_be_cancelled %} +
{% csrf_token %} + + +
+ {% endif %} + + Details +
+
diff --git a/rides/templates/rides/coverage.html b/rides/templates/rides/coverage.html new file mode 100644 index 0000000..c7d0300 --- /dev/null +++ b/rides/templates/rides/coverage.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Einzugsgebiet · Radeln ohne Alter{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +

Unser Einzugsgebiet

+

Wir holen Sie in Malstatt, St. Johann und Alt-Saarbrücken ab — kostenlos und ehrenamtlich. + „Für das Recht auf Wind in den Haaren."

+
+

Diese Seite ist öffentlich und enthält keine personenbezogenen Daten.

+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/rides/templates/rides/dashboard.html b/rides/templates/rides/dashboard.html new file mode 100644 index 0000000..106f0af --- /dev/null +++ b/rides/templates/rides/dashboard.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Übersicht · Radeln ohne Alter{% endblock %} +{% block content %} + +{% if role == 'coordinator' %} +
+

Übersicht

+ + Fahrt anlegen +
+
+ {% for s in status_counts %} + {{ s.label }} {{ s.n }} + {% endfor %} +
+
+ {% for ride in rides %} + {% include "rides/_ride_card.html" with ride=ride can_finish=True can_cancel=True %} + {% empty %} +

Keine anstehenden Fahrten.

+ {% endfor %} +
+ +{% elif role == 'pilot' %} +

Hallo {{ user.first_name|default:user.username }}

+ +
+

Offene Fahrten

+
+ {% for ride in open_rides %} + {% include "rides/_ride_card.html" with ride=ride can_claim=True %} + {% empty %} +

Gerade keine offenen Fahrten.

+ {% endfor %} +
+
+ +
+

Meine Fahrten

+
+ {% for ride in my_rides %} + {% include "rides/_ride_card.html" with ride=ride can_finish=True %} + {% empty %} +

Noch keine Fahrt übernommen.

+ {% endfor %} +
+
+ +
+

Meine Verfügbarkeit

+
    + {% for a in availabilities %} +
  • {{ a.start|date:"D, d.m. H:i" }} – {{ a.end|date:"H:i" }}
  • + {% empty %} +
  • Noch nichts eingetragen.
  • + {% endfor %} +
+
{% csrf_token %} + + + +
+
+ +{% else %} +
+

Meine Anfragen

+ + Fahrt anfragen +
+
+ {% for ride in my_requests %} + {% include "rides/_ride_card.html" with ride=ride %} + {% empty %} +

Noch keine Anfragen gestellt.

+ {% endfor %} +
+{% endif %} + +{% endblock %} diff --git a/rides/templates/rides/home.html b/rides/templates/rides/home.html new file mode 100644 index 0000000..83eb778 --- /dev/null +++ b/rides/templates/rides/home.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+

Fahrten koordinieren — ohne WhatsApp-Chaos

+

Verfügbarkeiten, Anfragen und Touren an einem Ort. Wähle eine Rolle, um die Demo auszuprobieren.

+ +
+ {% for u in demo_users %} +
{% csrf_token %} + +
+ {% empty %} +

Noch keine Demo-Daten. Bitte zuerst python manage.py seed_demo ausführen.

+ {% endfor %} +
+ +

Demo-Login ohne Passwort — nur in der lokalen Vorschau aktiv.

+
+{% endblock %} diff --git a/rides/templates/rides/map.html b/rides/templates/rides/map.html new file mode 100644 index 0000000..8261411 --- /dev/null +++ b/rides/templates/rides/map.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Karte · Radeln ohne Alter{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +

Fahrten-Karte

+

Abholorte der anstehenden und erledigten Fahrten. Farbe = Status.

+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/rides/templates/rides/ride_detail.html b/rides/templates/rides/ride_detail.html new file mode 100644 index 0000000..f64733b --- /dev/null +++ b/rides/templates/rides/ride_detail.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{{ ride.fahrgast.name }} · Fahrt{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +← Übersicht + +
+
+ {{ ride.get_status_display }} +

{{ ride.fahrgast.name }}

+
+
Wann
{{ ride.scheduled_at|date:"l, d. F Y, H:i" }} Uhr
+
Abholung
{{ ride.pickup_label }}
+
Ziel
{{ ride.dest_label }}
+ {% if ride.assigned_pilot %}
Pilot
{{ ride.assigned_pilot.get_full_name|default:ride.assigned_pilot.username }}
{% endif %} + {% if ride.fahrgast.einrichtung %}
Einrichtung
{{ ride.fahrgast.einrichtung.name }}
{% endif %} +
+ + {% if show_notes %} + {% if ride.fahrgast.mobility_notes %} +

Hinweise zum Fahrgast

{{ ride.fahrgast.mobility_notes }}

+ {% endif %} + {% if ride.note %} +

Notiz zur Fahrt

{{ ride.note }}

+ {% endif %} + {% else %} +

🔒 Pflege-Hinweise sind nur für die zugeteilte Pilot:in und die Koordination sichtbar.

+ {% endif %} +
+ +
+
+
+
+ +{{ ride.route_geojson|json_script:"route-data" }} +{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/rides/templates/rides/ride_form.html b/rides/templates/rides/ride_form.html new file mode 100644 index 0000000..5def3f1 --- /dev/null +++ b/rides/templates/rides/ride_form.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}Fahrt anlegen{% endblock %} +{% block head %} + +{% endblock %} +{% block content %} +← Übersicht +

Fahrt anlegen

+ +
{% csrf_token %} + {% if form.non_field_errors %}
{{ form.non_field_errors }}
{% endif %} + + + + +
+ + +
+ + + + {{ form.pickup_lat }}{{ form.pickup_lng }}{{ form.dest_lat }}{{ form.dest_lng }} + +

Marker auf der Karte ziehen: Abholung, + Ziel.

+
+ + +
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/rides/templates/rides/stats.html b/rides/templates/rides/stats.html new file mode 100644 index 0000000..0bf3e93 --- /dev/null +++ b/rides/templates/rides/stats.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Statistik · Radeln ohne Alter{% endblock %} +{% block content %} +← Übersicht +

Statistik

+ +
+ {{ total_done }} + erledigte Fahrten insgesamt +
+ +
+

Nach Status

+
+ {% for s in status_counts %} + {{ s.label }} {{ s.n }} + {% endfor %} +
+
+ +
+

Erledigte Fahrten je Pilot:in

+ + + + {% for row in per_pilot %} + + + + + {% empty %} + + {% endfor %} + +
Pilot:inFahrten
{{ row.assigned_pilot__first_name|default:row.assigned_pilot__username|default:"—" }}{{ row.n }}
Noch keine erledigten Fahrten.
+
+{% endblock %} diff --git a/rides/tests/__init__.py b/rides/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/rides/tests/test_models.py b/rides/tests/test_models.py new file mode 100644 index 0000000..4bd7c3a --- /dev/null +++ b/rides/tests/test_models.py @@ -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()) diff --git a/rides/tests/test_permissions.py b/rides/tests/test_permissions.py new file mode 100644 index 0000000..2a6795d --- /dev/null +++ b/rides/tests/test_permissions.py @@ -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") diff --git a/rides/urls.py b/rides/urls.py new file mode 100644 index 0000000..58acd2d --- /dev/null +++ b/rides/urls.py @@ -0,0 +1,20 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("", views.home, name="home"), + path("demo-login//", 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//", views.ride_detail, name="ride_detail"), + path("fahrten//uebernehmen/", views.ride_claim, name="ride_claim"), + path("fahrten//erledigt/", views.ride_done, name="ride_done"), + path("fahrten//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"), +] diff --git a/rides/views.py b/rides/views.py new file mode 100644 index 0000000..b5769c3 --- /dev/null +++ b/rides/views.py @@ -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(), + }, + ) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..62c5ec6 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,50 @@ +{% load static %} + + + + + + + {% block title %}Radeln ohne Alter{% endblock %} + + + {% block head %}{% endblock %} + + + + + {% if messages %} +
    + {% for m in messages %}
  • {{ m }}
  • {% endfor %} +
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + + {% block scripts %}{% endblock %} + +