left4me/l4d2web/routes/overlay_routes.py
mwiegand 923a1840f4
feat(web): forms in modals, edit/delete on detail pages, port auto-assign
- Native <dialog> modal infra (CSS + ~30 LOC JS, no framework) used for
  create forms and delete confirmations.
- Index pages become listing-only: + Create button opens a modal; the
  broken blueprint Actions column and inline overlay edit cells are gone.
- Server detail gains a blueprint reassignment form; existing Delete
  button now opens a confirmation modal before tearing down the runtime.
- Blueprint detail gains a Delete button + confirmation modal (was
  unreachable from the UI before).
- New overlay detail page at /overlays/<id> with edit form, "Used by"
  blueprints list, and delete (admin only).
- Server create: port field is now optional; backend auto-assigns the
  next free port from LEFT4ME_PORT_RANGE_START/_END (default
  27015-27115). 409 on range exhaustion.
- New routes: POST /blueprints/<id>/delete (form sentinel matching
  overlays pattern), POST /servers/<id> (form-friendly blueprint
  reassign), GET /overlays/<id>.
- Server delete operation now redirects to /servers; overlay update
  redirects to /overlays/<id>.

Server rename remains unsupported pending an id-vs-name design pass for
l4d2host (the runtime directory is name-keyed; renaming would orphan
files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 01:30:33 +02:00

72 lines
2.4 KiB
Python

from flask import Blueprint, Response, redirect, request
from sqlalchemy import select
from l4d2web.auth import require_admin
from l4d2web.db import session_scope
from l4d2web.models import BlueprintOverlay, Overlay
from l4d2web.services.security import validate_overlay_ref
bp = Blueprint("overlay", __name__)
@bp.post("/overlays")
@require_admin
def create_overlay() -> Response:
name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "")
if not name or not raw_path:
return Response("missing fields", status=400)
try:
overlay_ref = validate_overlay_ref(raw_path)
except ValueError as exc:
return Response(str(exc), status=400)
with session_scope() as db:
existing = db.scalar(select(Overlay).where(Overlay.name == name))
if existing is not None:
return Response("overlay already exists", status=409)
db.add(Overlay(name=name, path=overlay_ref))
return redirect("/overlays")
@bp.post("/overlays/<int:overlay_id>")
@require_admin
def update_overlay(overlay_id: int) -> Response:
name = request.form.get("name", "").strip()
raw_path = request.form.get("path", "")
if not name or not raw_path:
return Response("missing fields", status=400)
try:
overlay_ref = validate_overlay_ref(raw_path)
except ValueError as exc:
return Response(str(exc), status=400)
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
duplicate = db.scalar(select(Overlay).where(Overlay.name == name, Overlay.id != overlay_id))
if duplicate is not None:
return Response("overlay already exists", status=409)
overlay.name = name
overlay.path = overlay_ref
return redirect(f"/overlays/{overlay_id}")
@bp.post("/overlays/<int:overlay_id>/delete")
@require_admin
def delete_overlay(overlay_id: int) -> Response:
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
in_use = db.scalar(select(BlueprintOverlay).where(BlueprintOverlay.overlay_id == overlay_id))
if in_use is not None:
return Response("overlay is in use", status=409)
db.delete(overlay)
return redirect("/overlays")