feat(l4d2-web): per-overlay job list + redirect to job after build-triggering edits
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/<id>/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) <noreply@anthropic.com>
This commit is contained in:
parent
5e2c771276
commit
fb3c6be052
8 changed files with 137 additions and 46 deletions
|
|
@ -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/<int:overlay_id>/build")
|
||||
|
|
|
|||
|
|
@ -161,6 +161,27 @@ def overlays() -> str:
|
|||
return render_template("overlays.html", overlays=overlays)
|
||||
|
||||
|
||||
@bp.get("/overlays/<int:overlay_id>/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/<int:overlay_id>")
|
||||
@require_login
|
||||
def overlay_detail(overlay_id: int):
|
||||
|
|
|
|||
|
|
@ -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/<int:overlay_id>/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/<int:overlay_id>/items/<int:item_id>/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")
|
||||
|
|
|
|||
|
|
@ -64,21 +64,23 @@
|
|||
<label>Bash script
|
||||
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
|
||||
</label>
|
||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>. Saving auto-enqueues a build.</p>
|
||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
||||
<div>
|
||||
<button type="submit">Save</button>
|
||||
<button type="submit">Save and build</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<pre class="script-preview">{{ overlay.script or "" }}</pre>
|
||||
{% endif %}
|
||||
|
||||
{% if latest_build_job %}
|
||||
<p>
|
||||
{% if latest_build_job %}
|
||||
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
|
||||
— state: <strong>{{ latest_build_job.state }}</strong>
|
||||
·
|
||||
{% endif %}
|
||||
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
|
|
@ -114,16 +116,18 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{% if latest_build_job %}
|
||||
<section class="panel">
|
||||
<h2>Latest build</h2>
|
||||
<h2>Builds</h2>
|
||||
<p>
|
||||
<a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
|
||||
{% if latest_build_job %}
|
||||
Latest build: <a href="/jobs/{{ latest_build_job.id }}">job #{{ latest_build_job.id }}</a>
|
||||
— state: <strong>{{ latest_build_job.state }}</strong>
|
||||
·
|
||||
{% endif %}
|
||||
<a href="/overlays/{{ overlay.id }}/jobs">all builds →</a>
|
||||
</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Used by</h2>
|
||||
|
|
|
|||
17
l4d2web/templates/overlay_jobs.html
Normal file
17
l4d2web/templates/overlay_jobs.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Build jobs for {{ overlay.name }} | left4me{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="page-heading">
|
||||
<h1>Build jobs for {{ overlay.name }}</h1>
|
||||
<a href="/overlays/{{ overlay.id }}">Back to overlay</a>
|
||||
</div>
|
||||
{% set show_user = true %}
|
||||
{% set show_server = false %}
|
||||
{% set show_cancel = true %}
|
||||
{% set cancel_next = "/overlays/" ~ overlay.id ~ "/jobs" %}
|
||||
{% include "_job_table.html" %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue