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 import json
from flask import Blueprint, Response, jsonify, request from flask import Blueprint, Response, jsonify, redirect, request
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from l4d2web.auth import current_user, require_login from l4d2web.auth import current_user, require_login
@ -12,39 +12,88 @@ from l4d2web.models import BlueprintOverlay, Server
bp = Blueprint("blueprint", __name__) 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") @bp.post("/blueprints")
@require_login @require_login
def create_blueprint() -> Response: def create_blueprint() -> Response:
payload = request.get_json(silent=True) or {}
user = current_user() user = current_user()
assert user is not None 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: if not name:
return Response("name is required", status=400) return Response("name is required", status=400)
with session_scope() as db: with session_scope() as db:
blueprint = BlueprintModel( blueprint = BlueprintModel(user_id=user.id, name=name, arguments=json.dumps(arguments), config=json.dumps(config))
user_id=user.id,
name=name,
arguments=json.dumps(payload.get("arguments", [])),
config=json.dumps(payload.get("config", [])),
)
db.add(blueprint) db.add(blueprint)
db.flush() db.flush()
replace_blueprint_overlays(db, blueprint.id, overlay_ids)
for position, overlay_id in enumerate(payload.get("overlay_ids", [])):
db.add(
BlueprintOverlay(
blueprint_id=blueprint.id,
overlay_id=int(overlay_id),
position=position,
)
)
blueprint_id = blueprint.id 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>") @bp.delete("/blueprints/<int:blueprint_id>")

View file

@ -76,6 +76,18 @@ def overlays() -> str:
return render_template("overlays.html", overlays=overlays) 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>") @bp.get("/blueprints/<int:blueprint_id>")
@require_login @require_login
def blueprint_page(blueprint_id: int): def blueprint_page(blueprint_id: int):
@ -89,17 +101,26 @@ def blueprint_page(blueprint_id: int):
if blueprint.user_id != user.id: if blueprint.user_id != user.id:
return Response(status=403) return Response(status=403)
overlay_rows = db.execute( selected_overlays = db.scalars(
select(Overlay.name) select(Overlay)
.join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id) .join(BlueprintOverlay, BlueprintOverlay.overlay_id == Overlay.id)
.where(BlueprintOverlay.blueprint_id == blueprint.id) .where(BlueprintOverlay.blueprint_id == blueprint.id)
.order_by(BlueprintOverlay.position) .order_by(BlueprintOverlay.position)
).all() ).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( return render_template(
"blueprints.html", "blueprint_detail.html",
blueprint=blueprint, 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), arguments=json.loads(blueprint.arguments),
config_lines=json.loads(blueprint.config), 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" %} {% extends "base.html" %}
{% block title %}Blueprint | left4me{% endblock %} {% block title %}Blueprints | left4me{% endblock %}
{% block content %} {% block content %}
<section class="card"> <section class="panel">
<h1>Blueprint: {{ blueprint.name }}</h1> <h1>Blueprints</h1>
<h2>Overlays</h2> <form method="post" action="/blueprints" class="stack form-panel">
<ul> <input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
{% for name in overlay_names %} <label>Name <input name="name" required></label>
<li>{{ name }}</li> <label>Arguments <textarea name="arguments"></textarea></label>
{% else %} <label>Config <textarea name="config"></textarea></label>
<li class="muted">No overlays configured.</li> <button type="submit">Create blueprint</button>
{% endfor %} </form>
</ul> <table class="table">
<h2>Arguments</h2> <thead><tr><th>Name</th><th>Created</th><th>Updated</th><th>Actions</th></tr></thead>
<pre>{{ arguments | join('\n') }}</pre> <tbody>
<h2>Config</h2> {% for blueprint in blueprints %}
<pre>{{ config_lines | join('\n') }}</pre> <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> </section>
{% endblock %} {% endblock %}

View file

@ -83,3 +83,35 @@ def test_delete_blueprint_blocked_when_in_use(linked_blueprint) -> None:
client, blueprint_id = linked_blueprint client, blueprint_id = linked_blueprint
response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"}) response = client.delete(f"/blueprints/{blueprint_id}", headers={"X-CSRF-Token": "test-token"})
assert response.status_code == 409 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.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope 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 @pytest.fixture
@ -23,6 +23,11 @@ def auth_client_with_server(tmp_path, monkeypatch):
session.add(blueprint) session.add(blueprint)
session.flush() 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.add(Server(user_id=user.id, blueprint_id=blueprint.id, name="alpha", port=27015))
session.flush() session.flush()
user_id = user.id 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 client, blueprint_id = user_client_and_other_blueprint
response = client.get(f"/blueprints/{blueprint_id}") response = client.get(f"/blueprints/{blueprint_id}")
assert response.status_code == 403 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