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
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
db.sqlite3
|
||||||
|
.ruff_cache/
|
||||||
|
staticfiles/
|
||||||
|
.git/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
db.sqlite3
|
||||||
|
.ruff_cache/
|
||||||
|
/staticfiles/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
51
README.md
Normal file
51
README.md
Normal file
|
|
@ -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.
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
7
config/asgi.py
Normal file
7
config/asgi.py
Normal file
|
|
@ -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()
|
||||||
95
config/settings.py
Normal file
95
config/settings.py
Normal file
|
|
@ -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"
|
||||||
7
config/urls.py
Normal file
7
config/urls.py
Normal file
|
|
@ -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")),
|
||||||
|
]
|
||||||
7
config/wsgi.py
Normal file
7
config/wsgi.py
Normal file
|
|
@ -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()
|
||||||
16
manage.py
Normal file
16
manage.py
Normal file
|
|
@ -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()
|
||||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
|
|
@ -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"]
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
Django>=6.0,<6.1
|
||||||
|
requests>=2.31
|
||||||
|
whitenoise>=6.6
|
||||||
|
gunicorn>=23.0
|
||||||
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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
50
templates/base.html
Normal file
50
templates/base.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#2f7d57">
|
||||||
|
<title>{% block title %}Radeln ohne Alter{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{% static 'rides/css/app.css' %}">
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.3" defer></script>
|
||||||
|
{% block head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="{% url 'home' %}">
|
||||||
|
<span class="brand-mark" aria-hidden="true">🚲</span>
|
||||||
|
<span class="brand-text">Radeln <em>ohne Alter</em></span>
|
||||||
|
</a>
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{% url 'dashboard' %}">Übersicht</a>
|
||||||
|
<a href="{% url 'map' %}">Karte</a>
|
||||||
|
{% if profile.is_coordinator %}<a href="{% url 'stats' %}">Statistik</a>{% endif %}
|
||||||
|
<a href="{% url 'coverage' %}">Einzugsgebiet</a>
|
||||||
|
</nav>
|
||||||
|
<div class="user-chip">
|
||||||
|
{% if profile %}<span class="role-dot role-{{ profile.role }}" title="{{ profile.get_role_display }}"></span>{% endif %}
|
||||||
|
<span class="user-name">{{ user.get_full_name|default:user.username }}</span>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="inline">{% csrf_token %}<button class="link">abmelden</button></form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="messages container">
|
||||||
|
{% for m in messages %}<li class="msg msg-{{ m.tags }}">{{ m }}</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer container">
|
||||||
|
<span>Demo · Radeln ohne Alter Saarbrücken · ehrenamtliche Rikscha-Fahrten</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue