feat(l4d2-web): add form-based blueprint editor
This commit is contained in:
parent
71004a9deb
commit
feab09db07
6 changed files with 222 additions and 40 deletions
|
|
@ -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>")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
31
l4d2web/templates/blueprint_detail.html
Normal file
31
l4d2web/templates/blueprint_detail.html
Normal 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 %}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue