From fb3c6be052dafd89bfe74ebfa29d7ed76d8bb197 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Fri, 8 May 2026 17:44:22 +0200 Subject: [PATCH] feat(l4d2-web): per-overlay job list + redirect to job after build-triggering edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a script overlay or adding/removing workshop items now redirects to the enqueued build job's detail page so logs are immediately visible. Added a new /overlays//jobs page (linked as "all builds →" from the overlay detail page) for browsing the full build history. Renamed the script "Save" button to "Save and build" to make the side effect explicit. Co-Authored-By: Claude Opus 4.7 (1M context) --- l4d2web/routes/overlay_routes.py | 5 +- l4d2web/routes/page_routes.py | 21 +++++++ l4d2web/routes/workshop_routes.py | 36 +++--------- l4d2web/templates/overlay_detail.html | 20 ++++--- l4d2web/templates/overlay_jobs.html | 17 ++++++ l4d2web/tests/test_pages.py | 63 +++++++++++++++++++++ l4d2web/tests/test_script_overlay_routes.py | 3 + l4d2web/tests/test_workshop_routes.py | 18 +++--- 8 files changed, 137 insertions(+), 46 deletions(-) create mode 100644 l4d2web/templates/overlay_jobs.html diff --git a/l4d2web/routes/overlay_routes.py b/l4d2web/routes/overlay_routes.py index 2d1d1a4..a2731c9 100644 --- a/l4d2web/routes/overlay_routes.py +++ b/l4d2web/routes/overlay_routes.py @@ -155,8 +155,9 @@ def update_script(overlay_id: int) -> Response: if err is not None: return err overlay.script = script_text - enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) - return redirect(f"/overlays/{overlay_id}") + job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job_id = job.id + return redirect(f"/jobs/{job_id}") @bp.post("/overlays//build") diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 054381e..d796525 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -161,6 +161,27 @@ def overlays() -> str: return render_template("overlays.html", overlays=overlays) +@bp.get("/overlays//jobs") +@require_login +def overlay_jobs_page(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) + if not user.admin and overlay.user_id is not None and overlay.user_id != user.id: + return Response(status=403) + rows = db.execute( + select(Job, User, Server) + .outerjoin(User, User.id == Job.user_id) + .outerjoin(Server, Server.id == Job.server_id) + .where(Job.operation == "build_overlay", Job.overlay_id == overlay.id) + .order_by(Job.created_at.desc()) + ).all() + return render_template("overlay_jobs.html", overlay=overlay, rows=rows) + + @bp.get("/overlays/") @require_login def overlay_detail(overlay_id: int): diff --git a/l4d2web/routes/workshop_routes.py b/l4d2web/routes/workshop_routes.py index 9b653a6..4fae85a 100644 --- a/l4d2web/routes/workshop_routes.py +++ b/l4d2web/routes/workshop_routes.py @@ -2,7 +2,7 @@ admin global refresh).""" from __future__ import annotations -from flask import Blueprint, Response, redirect, render_template, request +from flask import Blueprint, Response, redirect, request from sqlalchemy import delete as sa_delete from sqlalchemy import select @@ -32,30 +32,6 @@ def _check_workshop_overlay_access(overlay_id: int, user, db): return overlay, None -def _render_item_table(overlay_id: int): - with session_scope() as db: - overlay = db.scalar(select(Overlay).where(Overlay.id == overlay_id)) - 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() - # Detach so attributes survive after the session closes. - for item in items: - db.expunge(item) - if overlay is not None: - db.expunge(overlay) - return render_template( - "_overlay_item_table.html", - overlay=overlay, - workshop_items=items, - ) - - @bp.post("/overlays//items") @require_login def add_items(overlay_id: int) -> Response: @@ -116,9 +92,10 @@ def add_items(overlay_id: int) -> Response: if existing is None: db.add(OverlayWorkshopItem(overlay_id=overlay_id, workshop_item_id=wi.id)) - enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job_id = job.id - return _render_item_table(overlay_id) + return redirect(f"/jobs/{job_id}") @bp.post("/overlays//items//delete") @@ -138,8 +115,9 @@ def remove_item(overlay_id: int, item_id: int) -> Response: ) if result.rowcount == 0: return Response(status=404) - enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) - return _render_item_table(overlay_id) + job = enqueue_build_overlay(db, overlay_id=overlay_id, user_id=user.id) + job_id = job.id + return redirect(f"/jobs/{job_id}") @bp.post("/admin/workshop/refresh") diff --git a/l4d2web/templates/overlay_detail.html b/l4d2web/templates/overlay_detail.html index 40148d5..460fed2 100644 --- a/l4d2web/templates/overlay_detail.html +++ b/l4d2web/templates/overlay_detail.html @@ -64,21 +64,23 @@ -

Runs sandboxed against the overlay directory mounted at /overlay. Saving auto-enqueues a build.

+

Runs sandboxed against the overlay directory mounted at /overlay.

- +
{% else %}
{{ overlay.script or "" }}
{% endif %} - {% if latest_build_job %}

+ {% if latest_build_job %} Latest build: job #{{ latest_build_job.id }} — state: {{ latest_build_job.state }} + · + {% endif %} + all builds →

- {% endif %} {% endif %} @@ -114,16 +116,18 @@ -{% if latest_build_job %}
-

Latest build

+

Builds

- job #{{ latest_build_job.id }} + {% if latest_build_job %} + Latest build: job #{{ latest_build_job.id }} — state: {{ latest_build_job.state }} + · + {% endif %} + all builds →

{% endif %} -{% endif %}

Used by

diff --git a/l4d2web/templates/overlay_jobs.html b/l4d2web/templates/overlay_jobs.html new file mode 100644 index 0000000..e7b1673 --- /dev/null +++ b/l4d2web/templates/overlay_jobs.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Build jobs for {{ overlay.name }} | left4me{% endblock %} + +{% block content %} +
+
+

Build jobs for {{ overlay.name }}

+ Back to overlay +
+ {% set show_user = true %} + {% set show_server = false %} + {% set show_cancel = true %} + {% set cancel_next = "/overlays/" ~ overlay.id ~ "/jobs" %} + {% include "_job_table.html" %} +
+{% endblock %} diff --git a/l4d2web/tests/test_pages.py b/l4d2web/tests/test_pages.py index 5685924..93da249 100644 --- a/l4d2web/tests/test_pages.py +++ b/l4d2web/tests/test_pages.py @@ -561,6 +561,69 @@ def test_overlay_detail_workshop_section_unchanged(auth_client_with_server) -> N assert "Workshop items" in text +def test_overlay_detail_links_to_overlay_jobs_page(auth_client_with_server) -> None: + with session_scope() as s: + user_id = s.query(User).filter_by(username="alice").one().id + overlay_id = _seed_overlay("scripted", "script", user_id) + + response = auth_client_with_server.get(f"/overlays/{overlay_id}") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert f'href="/overlays/{overlay_id}/jobs"' in text + assert "Save and build" in text + + +def test_overlay_jobs_page_lists_overlay_builds(auth_client_with_server) -> None: + with session_scope() as s: + user_id = s.query(User).filter_by(username="alice").one().id + overlay_id = _seed_overlay("scripted", "script", user_id) + other_overlay_id = _seed_overlay("other", "script", user_id) + + with session_scope() as session: + session.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="succeeded")) + session.add(Job(user_id=user_id, overlay_id=overlay_id, operation="build_overlay", state="queued")) + # Job for a different overlay must not appear. + session.add(Job(user_id=user_id, overlay_id=other_overlay_id, operation="build_overlay", state="succeeded")) + + response = auth_client_with_server.get(f"/overlays/{overlay_id}/jobs") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert "Build jobs for scripted" in text + assert text.count("build_overlay") == 2 + assert f'href="/overlays/{overlay_id}"' in text + + +def test_overlay_jobs_page_404_for_unknown_overlay(auth_client_with_server) -> None: + response = auth_client_with_server.get("/overlays/9999/jobs") + assert response.status_code == 404 + + +def test_overlay_jobs_page_403_for_other_users_private_overlay(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'overlay-jobs-403.db'}" + monkeypatch.setenv("DATABASE_URL", db_url) + app = create_app({"TESTING": True, "DATABASE_URL": db_url, "SECRET_KEY": "test"}) + init_db() + + with session_scope() as session: + owner = User(username="owner", password_digest=hash_password("x"), admin=False) + other = User(username="other", password_digest=hash_password("x"), admin=False) + session.add_all([owner, other]) + session.flush() + owner_id = owner.id + other_id = other.id + + overlay_id = _seed_overlay("private", "script", owner_id) + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = other_id + + response = client.get(f"/overlays/{overlay_id}/jobs") + assert response.status_code == 403 + + def test_overlay_detail_no_global_source_block(auth_client_with_server) -> None: with session_scope() as s: user_id = s.query(User).filter_by(username="alice").one().id diff --git a/l4d2web/tests/test_script_overlay_routes.py b/l4d2web/tests/test_script_overlay_routes.py index e4ccc17..edb2a72 100644 --- a/l4d2web/tests/test_script_overlay_routes.py +++ b/l4d2web/tests/test_script_overlay_routes.py @@ -121,11 +121,13 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None: headers={"X-CSRF-Token": "test-token"}, ) assert r1.status_code == 302 + assert r1.headers["Location"].startswith("/jobs/") with session_scope() as s: overlay = s.query(Overlay).filter_by(id=overlay_id).one() assert overlay.script == "echo new" jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 + assert r1.headers["Location"] == f"/jobs/{jobs[0].id}" # Coalesce against pending. r2 = client.post( @@ -134,6 +136,7 @@ def test_update_script_body_enqueues_build(app, alice_id) -> None: headers={"X-CSRF-Token": "test-token"}, ) assert r2.status_code == 302 + assert r2.headers["Location"] == r1.headers["Location"] with session_scope() as s: jobs = s.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 diff --git a/l4d2web/tests/test_workshop_routes.py b/l4d2web/tests/test_workshop_routes.py index 5bec803..cd73b19 100644 --- a/l4d2web/tests/test_workshop_routes.py +++ b/l4d2web/tests/test_workshop_routes.py @@ -93,8 +93,8 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for): data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) - assert response.status_code == 200 - assert b"1001" in response.data + assert response.status_code == 302 + assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: n_assoc = session.query(OverlayWorkshopItem).count() @@ -106,6 +106,7 @@ def test_add_single_item_creates_association_and_enqueues_build(overlay_for): jobs = session.query(Job).filter_by(operation="build_overlay", overlay_id=overlay_id).all() assert len(jobs) == 1 assert jobs[0].state == "queued" + assert response.headers["Location"] == f"/jobs/{jobs[0].id}" def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for): @@ -118,7 +119,8 @@ def test_add_multiline_batch_coalesces_into_one_build_job(overlay_for): data={"input": "1001\n1002\n1003", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) - assert response.status_code == 200 + assert response.status_code == 302 + assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 3 @@ -137,7 +139,8 @@ def test_add_collection_resolves_members(overlay_for): data={"input": "555", "input_mode": "collection"}, headers={"X-CSRF-Token": "test-token"}, ) - assert response.status_code == 200 + assert response.status_code == 302 + assert response.headers["Location"].startswith("/jobs/") resolve.assert_called_once_with("555") with session_scope() as session: @@ -175,7 +178,7 @@ def test_add_duplicate_item_does_not_500(overlay_for): data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) - assert first.status_code == 200 + assert first.status_code == 302 with _patch_steam([_meta("1001")]): second = user_client.post( @@ -183,7 +186,7 @@ def test_add_duplicate_item_does_not_500(overlay_for): data={"input": "1001", "input_mode": "items"}, headers={"X-CSRF-Token": "test-token"}, ) - assert second.status_code == 200 + assert second.status_code == 302 with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 1 @@ -208,7 +211,8 @@ def test_remove_item_drops_association_and_enqueues_rebuild(overlay_for): f"/overlays/{overlay_id}/items/{item_id}/delete", headers={"X-CSRF-Token": "test-token"}, ) - assert response.status_code == 200 + assert response.status_code == 302 + assert response.headers["Location"].startswith("/jobs/") with session_scope() as session: assert session.query(OverlayWorkshopItem).count() == 0