272 lines
8.8 KiB
Python
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(),
|
|
},
|
|
)
|