feat(l4d2-web): add form-based blueprint editor

This commit is contained in:
mwiegand 2026-05-06 12:09:08 +02:00
parent 71004a9deb
commit feab09db07
No known key found for this signature in database
6 changed files with 222 additions and 40 deletions

View file

@ -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/<int:blueprint_id>")
@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/<int:blueprint_id>")

View file

@ -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/<int:blueprint_id>")
@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),
)

View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Blueprint {{ blueprint.name }} | left4me{% endblock %}
{% block content %}
<section class="panel">
<h1>Blueprint: {{ blueprint.name }}</h1>
<form method="post" action="/blueprints/{{ blueprint.id }}" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" value="{{ blueprint.name }}" required></label>
<p class="muted">Overlay order matters: the first overlay has highest precedence.</p>
<table class="table">
<thead><tr><th>Use</th><th>Order</th><th>Overlay</th></tr></thead>
<tbody>
{% for overlay in all_overlays %}
<tr>
<td><input type="checkbox" name="overlay_ids" value="{{ overlay.id }}" {% if overlay.id in selected_overlay_ids %}checked{% endif %}></td>
<td><input class="position-input" name="overlay_position_{{ overlay.id }}" value="{{ overlay_positions.get(overlay.id, '') }}" inputmode="numeric"></td>
<td>{{ overlay.name }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">No overlays available.</td></tr>
{% endfor %}
</tbody>
</table>
<label>Arguments <textarea name="arguments">{{ arguments | join('\n') }}</textarea></label>
<label>Config <textarea name="config">{{ config_lines | join('\n') }}</textarea></label>
<button type="submit">Save blueprint</button>
</form>
</section>
{% endblock %}

View file

@ -1,21 +1,36 @@
{% extends "base.html" %}
{% block title %}Blueprint | left4me{% endblock %}
{% block title %}Blueprints | left4me{% endblock %}
{% block content %}
<section class="card">
<h1>Blueprint: {{ blueprint.name }}</h1>
<h2>Overlays</h2>
<ul>
{% for name in overlay_names %}
<li>{{ name }}</li>
{% else %}
<li class="muted">No overlays configured.</li>
{% endfor %}
</ul>
<h2>Arguments</h2>
<pre>{{ arguments | join('\n') }}</pre>
<h2>Config</h2>
<pre>{{ config_lines | join('\n') }}</pre>
<section class="panel">
<h1>Blueprints</h1>
<form method="post" action="/blueprints" class="stack form-panel">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Name <input name="name" required></label>
<label>Arguments <textarea name="arguments"></textarea></label>
<label>Config <textarea name="config"></textarea></label>
<button type="submit">Create blueprint</button>
</form>
<table class="table">
<thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
<tbody>
{% for blueprint in blueprints %}
<tr>
<td><a href="/blueprints/{{ blueprint.id }}">{{ blueprint.name }}</a></td>
<td>{{ blueprint.created_at }}</td>
<td>{{ blueprint.updated_at }}</td>
<td>
<form method="post" action="/blueprints/{{ blueprint.id }}/delete" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<button class="danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4" class="muted">No blueprints configured.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -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"

View file

@ -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 '<a href="/blueprints/1">default</a>' 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