left4me/l4d2web/routes/page_routes.py
mwiegand 92d6ebbe82
feat(l4d2-web): managed global map overlays with daily refresh
Adds two managed system overlays (l4d2center-maps, cedapug-maps) that
fetch curated map archives from upstream sources and reconcile addons
symlinks for non-Steam maps. A daily systemd timer enqueues a coalesced
refresh_global_overlays worker job; downloads, extraction, and rebuilds
run in the existing job worker and surface in the job log UI.

Schema: GlobalOverlaySource / GlobalOverlayItem / GlobalOverlayItemFile
plus nullable Job.user_id so system jobs render as "system" in the UI.
The new builder reconciles symlinks against the per-source vpk cache
and leaves foreign symlinks untouched. Initialize-time guard refuses
to mount a partial overlay if any expected vpk is missing from cache.

Refresh service uses shutil.move to handle EXDEV when /tmp and the
cache live on different filesystems.

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

282 lines
8.9 KiB
Python

import json
from flask import Blueprint, Response, redirect, render_template, request
from sqlalchemy import select
from l4d2web.auth import current_user, require_admin, require_login
from l4d2web.db import session_scope
from l4d2web.models import Blueprint as BlueprintModel
from l4d2web.models import (
BlueprintOverlay,
GlobalOverlaySource,
Job,
Overlay,
OverlayWorkshopItem,
Server,
User,
WorkshopItem,
)
bp = Blueprint("pages", __name__)
@bp.get("/dashboard")
@require_login
def dashboard() -> str:
return render_template("dashboard.html")
@bp.get("/admin")
@require_admin
def admin_home() -> str:
return render_template("admin.html")
@bp.post("/admin/install")
@require_admin
def enqueue_runtime_install() -> Response:
user = current_user()
assert user is not None
with session_scope() as db:
db.add(Job(user_id=user.id, server_id=None, operation="install", state="queued"))
return redirect("/admin/jobs")
@bp.post("/admin/global-overlays/refresh")
@require_admin
def enqueue_global_overlay_refresh() -> Response:
user = current_user()
assert user is not None
from l4d2web.services.global_overlays import (
ensure_global_overlays,
enqueue_refresh_global_overlays,
)
with session_scope() as db:
ensure_global_overlays(db)
enqueue_refresh_global_overlays(db, user_id=user.id)
return redirect("/admin/jobs")
@bp.get("/admin/users")
@require_admin
def admin_users() -> str:
with session_scope() as db:
users = db.scalars(select(User).order_by(User.username)).all()
return render_template("admin_users.html", users=users)
@bp.get("/admin/jobs")
@require_admin
def admin_jobs() -> str:
with session_scope() as db:
rows = db.execute(
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.order_by(Job.created_at.desc())
).all()
return render_template("admin_jobs.html", rows=rows)
@bp.get("/servers")
@require_login
def servers_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
rows = db.execute(
select(Server, BlueprintModel)
.join(BlueprintModel, BlueprintModel.id == Server.blueprint_id)
.where(Server.user_id == user.id)
.order_by(Server.name)
).all()
blueprints = db.scalars(
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all()
prefill_blueprint_id: int | None = None
raw_prefill = request.args.get("blueprint_id")
if raw_prefill:
try:
candidate = int(raw_prefill)
except ValueError:
candidate = None
if candidate is not None and any(b.id == candidate for b in blueprints):
prefill_blueprint_id = candidate
return render_template(
"servers.html",
rows=rows,
blueprints=blueprints,
prefill_blueprint_id=prefill_blueprint_id,
)
@bp.get("/servers/<int:server_id>")
@require_login
def server_detail(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == server.blueprint_id))
recent_job_rows = db.execute(
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
.limit(5)
).all()
return render_template(
"server_detail.html",
server=server,
blueprint=blueprint,
recent_job_rows=recent_job_rows,
)
@bp.get("/servers/<int:server_id>/jobs")
@require_login
def server_jobs_page(server_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
server = db.scalar(select(Server).where(Server.id == server_id, Server.user_id == user.id))
if server is None:
return Response(status=404)
rows = db.execute(
select(Job, User, Server)
.outerjoin(User, User.id == Job.user_id)
.outerjoin(Server, Server.id == Job.server_id)
.where(Job.server_id == server.id)
.order_by(Job.created_at.desc())
).all()
return render_template("server_jobs.html", server=server, rows=rows)
@bp.get("/overlays")
@require_login
def overlays() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
query = select(Overlay).order_by(Overlay.name)
if not user.admin:
query = query.where(
Overlay.user_id.is_(None) | (Overlay.user_id == user.id)
)
overlays = db.scalars(query).all()
return render_template("overlays.html", overlays=overlays)
@bp.get("/overlays/<int:overlay_id>")
@require_login
def overlay_detail(overlay_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id))
if overlay is None:
return Response(status=404)
if not user.admin and overlay.user_id is not None and overlay.user_id != user.id:
return Response(status=403)
global_source = db.scalar(
select(GlobalOverlaySource).where(GlobalOverlaySource.overlay_id == overlay.id)
)
using_blueprints_query = (
select(BlueprintModel)
.join(BlueprintOverlay, BlueprintOverlay.blueprint_id == BlueprintModel.id)
.where(BlueprintOverlay.overlay_id == overlay.id)
.order_by(BlueprintModel.name)
)
if not user.admin:
using_blueprints_query = using_blueprints_query.where(BlueprintModel.user_id == user.id)
using_blueprints = db.scalars(using_blueprints_query).all()
workshop_items = []
if overlay.type == "workshop":
workshop_items = db.scalars(
select(WorkshopItem)
.join(
OverlayWorkshopItem,
OverlayWorkshopItem.workshop_item_id == WorkshopItem.id,
)
.where(OverlayWorkshopItem.overlay_id == overlay.id)
.order_by(WorkshopItem.created_at)
).all()
latest_build_job = db.scalar(
select(Job)
.where(Job.operation == "build_overlay", Job.overlay_id == overlay.id)
.order_by(Job.created_at.desc())
.limit(1)
)
return render_template(
"overlay_detail.html",
overlay=overlay,
global_source=global_source,
using_blueprints=using_blueprints,
workshop_items=workshop_items,
latest_build_job=latest_build_job,
)
@bp.get("/blueprints")
@require_login
def blueprints_page() -> str:
user = current_user()
assert user is not None
with session_scope() as db:
blueprints = db.scalars(
select(BlueprintModel).where(BlueprintModel.user_id == user.id).order_by(BlueprintModel.name)
).all()
return render_template("blueprints.html", blueprints=blueprints)
@bp.get("/blueprints/<int:blueprint_id>")
@require_login
def blueprint_page(blueprint_id: int):
user = current_user()
assert user is not None
with session_scope() as db:
blueprint = db.scalar(select(BlueprintModel).where(BlueprintModel.id == blueprint_id))
if blueprint is None:
return Response(status=404)
if blueprint.user_id != user.id:
return Response(status=403)
selected_overlays = db.scalars(
select(Overlay)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position)
).all()
position_rows = db.execute(
select(BlueprintOverlay.overlay_id, BlueprintOverlay.position)
.where(BlueprintOverlay.blueprint_id == blueprint.id)
).all()
all_overlays = db.scalars(
select(Overlay)
.where(Overlay.user_id.is_(None) | (Overlay.user_id == user.id))
.order_by(Overlay.name)
).all()
overlay_positions = {overlay_id: position + 1 for overlay_id, position in position_rows}
return render_template(
"blueprint_detail.html",
blueprint=blueprint,
selected_overlays=selected_overlays,
all_overlays=all_overlays,
selected_overlay_ids={overlay.id for overlay in selected_overlays},
overlay_positions=overlay_positions,
arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config),
)