Adds workshop_routes blueprint with add-items / remove-item / manual- build endpoints plus admin /admin/workshop/refresh. Add-items handles single ID, single URL, multi-line batch, or a collection ID; auto- enqueues a coalesced build_overlay job per call. Reject non-L4D2 items with 400, duplicate associations with friendly toast, intruders with 403. Generalizes overlay_routes: type+name only on create (no path field); external is admin-only and system-wide, workshop is per-user and auto-pathed. Update is name-only. Delete recursively removes the on-disk dir only for managed paths (path == str(id)); legacy externals are left in place. The pre-existing in-use guard is preserved. Page routes filter the overlay listing by user permissions and load workshop items + the latest related job for the detail view. Templates: unified Create modal with type radio (no path field). Type-aware overlay detail: workshop overlays show a multi-line input + items/collection radio + item table partial with thumbnails, manual Rebuild button, and a small status indicator pulled from the latest related job. Admin page gets a "Refresh all workshop items" button. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
256 lines
8.1 KiB
Python
256 lines
8.1 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,
|
|
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.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)
|
|
.join(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)
|
|
.join(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)
|
|
.join(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.type == "external") | (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)
|
|
# Visibility: externals are visible to all; workshop overlays are
|
|
# visible to the owner and admins.
|
|
if overlay.type == "workshop" and not user.admin and overlay.user_id != user.id:
|
|
return Response(status=403)
|
|
using_blueprints = db.scalars(
|
|
select(BlueprintModel)
|
|
.join(BlueprintOverlay, BlueprintOverlay.blueprint_id == BlueprintModel.id)
|
|
.where(BlueprintOverlay.overlay_id == overlay.id)
|
|
.order_by(BlueprintModel.name)
|
|
).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,
|
|
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).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),
|
|
)
|