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