radeln/rides/views.py
steffen1971 5d967c68b5 Add Radeln ohne Alter ride-coordination demo (Django + HTMX + Leaflet)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:20:17 +02:00

272 lines
8.8 KiB
Python

"""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(),
},
)