From feab09db0755593e647321754931d345a9a14281 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 12:09:08 +0200 Subject: [PATCH] feat(l4d2-web): add form-based blueprint editor --- l4d2web/routes/blueprint_routes.py | 89 +++++++++++++++++++------ l4d2web/routes/page_routes.py | 29 ++++++-- l4d2web/templates/blueprint_detail.html | 31 +++++++++ l4d2web/templates/blueprints.html | 45 ++++++++----- l4d2web/tests/test_blueprints.py | 32 +++++++++ l4d2web/tests/test_pages.py | 36 +++++++++- 6 files changed, 222 insertions(+), 40 deletions(-) create mode 100644 l4d2web/templates/blueprint_detail.html diff --git a/l4d2web/routes/blueprint_routes.py b/l4d2web/routes/blueprint_routes.py index 0af6df0..a31bb71 100644 --- a/l4d2web/routes/blueprint_routes.py +++ b/l4d2web/routes/blueprint_routes.py @@ -1,6 +1,6 @@ import json -from flask import Blueprint, Response, jsonify, request +from flask import Blueprint, Response, jsonify, redirect, request from sqlalchemy import delete, func, select from l4d2web.auth import current_user, require_login @@ -12,39 +12,88 @@ from l4d2web.models import BlueprintOverlay, Server bp = Blueprint("blueprint", __name__) +def split_textarea_lines(raw: str) -> list[str]: + return [line.strip() for line in raw.splitlines() if line.strip()] + + +def ordered_overlay_ids_from_form() -> list[int]: + ordered = [] + for fallback_position, value in enumerate(request.form.getlist("overlay_ids")): + if not value: + continue + overlay_id = int(value) + raw_position = request.form.get(f"overlay_position_{overlay_id}", "").strip() + try: + position = int(raw_position) + except ValueError: + position = fallback_position + 1 + ordered.append((position, fallback_position, overlay_id)) + return [overlay_id for _, _, overlay_id in sorted(ordered)] + + +def replace_blueprint_overlays(db, blueprint_id: int, overlay_ids: list[int]) -> None: + db.execute(delete(BlueprintOverlay).where(BlueprintOverlay.blueprint_id == blueprint_id)) + for position, overlay_id in enumerate(overlay_ids): + db.add(BlueprintOverlay(blueprint_id=blueprint_id, overlay_id=overlay_id, position=position)) + + @bp.post("/blueprints") @require_login def create_blueprint() -> Response: - payload = request.get_json(silent=True) or {} user = current_user() assert user is not None - name = str(payload.get("name", "")).strip() + if request.is_json: + payload = request.get_json(silent=True) or {} + name = str(payload.get("name", "")).strip() + arguments = [str(item) for item in payload.get("arguments", [])] + config = [str(item) for item in payload.get("config", [])] + overlay_ids = [int(item) for item in payload.get("overlay_ids", [])] + json_response = True + else: + name = request.form.get("name", "").strip() + arguments = split_textarea_lines(request.form.get("arguments", "")) + config = split_textarea_lines(request.form.get("config", "")) + overlay_ids = ordered_overlay_ids_from_form() + json_response = False + if not name: return Response("name is required", status=400) with session_scope() as db: - blueprint = BlueprintModel( - user_id=user.id, - name=name, - arguments=json.dumps(payload.get("arguments", [])), - config=json.dumps(payload.get("config", [])), - ) + blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config)) db.add(blueprint) db.flush() - - for position, overlay_id in enumerate(payload.get("overlay_ids", [])): - db.add( - BlueprintOverlay( - blueprint_id=blueprint.id, - overlay_id=int(overlay_id), - position=position, - ) - ) - + replace_blueprint_overlays(db, blueprint.id, overlay_ids) blueprint_id = blueprint.id - return jsonify({"id": blueprint_id}), 201 + if json_response: + return jsonify({"id": blueprint_id}), 201 + return redirect(f"/blueprints/{blueprint_id}") + + +@bp.post("/blueprints/") +@require_login +def update_blueprint_form(blueprint_id: int) -> Response: + user = current_user() + assert user is not None + name = request.form.get("name", "").strip() + if not name: + return Response("name is required", status=400) + + with session_scope() as db: + blueprint = db.scalar( + select(BlueprintModel).where(BlueprintModel.id == blueprint_id, BlueprintModel.user_id == user.id) + ) + if blueprint is None: + return Response(status=404) + + blueprint.name = name + blueprint.arguments = json.dumps(split_textarea_lines(request.form.get("arguments", ""))) + blueprint.config = json.dumps(split_textarea_lines(request.form.get("config", ""))) + replace_blueprint_overlays(db, blueprint.id, ordered_overlay_ids_from_form()) + + return redirect(f"/blueprints/{blueprint_id}") @bp.delete("/blueprints/") diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 220d29a..75a4dab 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -76,6 +76,18 @@ def overlays() -> str: return render_template("overlays.html", overlays=overlays) +@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/") @require_login def blueprint_page(blueprint_id: int): @@ -89,17 +101,26 @@ def blueprint_page(blueprint_id: int): if blueprint.user_id != user.id: return Response(status=403) - overlay_rows = db.execute( - select(Overlay.name) + 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( - "blueprints.html", + "blueprint_detail.html", blueprint=blueprint, - overlay_names=[row[0] for row in overlay_rows], + 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), ) diff --git a/l4d2web/templates/blueprint_detail.html b/l4d2web/templates/blueprint_detail.html new file mode 100644 index 0000000..2ea9cd8 --- /dev/null +++ b/l4d2web/templates/blueprint_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %} + +{% block content %} +
+

Blueprint: {{ blueprint.name }}

+
+ + +

Overlay order matters: the first overlay has highest precedence.

+ + + + {% for overlay in all_overlays %} + + + + + + {% else %} + + {% endfor %} + +
UseOrderOverlay
{{ overlay.name }}
No overlays available.
+ + + +
+
+{% endblock %} diff --git a/l4d2web/templates/blueprints.html b/l4d2web/templates/blueprints.html index 9208b61..3bf5939 100644 --- a/l4d2web/templates/blueprints.html +++ b/l4d2web/templates/blueprints.html @@ -1,21 +1,36 @@ {% extends "base.html" %} -{% block title %}Blueprint | left4me{% endblock %} +{% block title %}Blueprints | left4me{% endblock %} {% block content %} -
-

Blueprint: {{ blueprint.name }}

-

Overlays

-
    - {% for name in overlay_names %} -
  • {{ name }}
  • - {% else %} -
  • No overlays configured.
  • - {% endfor %} -
-

Arguments

-
{{ arguments | join('\n') }}
-

Config

-
{{ config_lines | join('\n') }}
+
+

Blueprints

+
+ + + + + +
+ + + + {% for blueprint in blueprints %} + + + + + + + {% else %} + + {% endfor %} + +
NameCreatedUpdatedActions
{{ blueprint.name }}{{ blueprint.created_at }}{{ blueprint.updated_at }} +
+ + +
+
No blueprints configured.
{% endblock %} diff --git a/l4d2web/tests/test_blueprints.py b/l4d2web/tests/test_blueprints.py index 8d36d5a..eadbda2 100644 --- a/l4d2web/tests/test_blueprints.py +++ b/l4d2web/tests/test_blueprints.py @@ -83,3 +83,35 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None: client, blueprint_id = linked_blueprint response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"}) assert response.status_code == 409 + + +def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client) -> None: + create = user_client.post( + "/blueprints", + data={ + "name": "comp", + "arguments": "-tickrate 100", + "config": "sv_consistency 1", + "overlay_ids": ["2", "1"], + "overlay_position_1": "2", + "overlay_position_2": "1", + }, + headers={"X-CSRF-Token": "test-token"}, + ) + assert create.status_code == 302 + + update = user_client.post( + "/blueprints/1", + data={ + "name": "edited", + "arguments": "-tickrate 100\n+sv_lan 0", + "config": "sv_consistency 1\nsv_allow_lobby_connect_only 0", + "overlay_ids": ["1", "2"], + "overlay_position_1": "1", + "overlay_position_2": "2", + }, + headers={"X-CSRF-Token": "test-token"}, + ) + + assert update.status_code == 302 + assert update.headers["Location"] == "/blueprints/1" diff --git a/l4d2web/tests/test_pages.py b/l4d2web/tests/test_pages.py index e9c7ee4..045787b 100644 --- a/l4d2web/tests/test_pages.py +++ b/l4d2web/tests/test_pages.py @@ -4,7 +4,7 @@ from pathlib import Path from l4d2web.app import create_app from l4d2web.auth import hash_password from l4d2web.db import init_db, session_scope -from l4d2web.models import Blueprint, Server, User +from l4d2web.models import Blueprint, BlueprintOverlay, Overlay, Server, User @pytest.fixture @@ -23,6 +23,11 @@ def auth_client_with_server(tmp_path, monkeypatch): session.add(blueprint) session.flush() + overlay = Overlay(name="standard", path="/opt/l4d2/overlays/standard") + session.add(overlay) + session.flush() + + session.add(BlueprintOverlay(blueprint_id=blueprint.id, overlay_id=overlay.id, position=0)) session.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015)) session.flush() user_id = user.id @@ -136,3 +141,32 @@ def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> N client, blueprint_id = user_client_and_other_blueprint response = client.get(f"/blueprints/{blueprint_id}") assert response.status_code == 403 + + +def test_blueprint_pages_fixture_has_ordered_overlay_data(auth_client_with_server) -> None: + response = auth_client_with_server.get("/blueprints/1") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "standard" in text + + +def test_blueprints_page_links_blueprint_names(auth_client_with_server) -> None: + response = auth_client_with_server.get("/blueprints") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert 'default' in text + assert "View" not in text + + +def test_blueprint_detail_has_ordered_overlay_form(auth_client_with_server) -> None: + response = auth_client_with_server.get("/blueprints/1") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Overlay order matters" in text + assert 'name="arguments"' in text + assert 'name="config"' in text + assert 'name="overlay_ids"' in text + assert 'name="overlay_position_1"' in text