From 4b326736fe78e5504e0638309e5c0e344caf3746 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Wed, 6 May 2026 12:09:36 +0200 Subject: [PATCH] feat(l4d2-web): add admin landing and system pages --- l4d2web/routes/page_routes.py | 29 +++++++++++++++++++++++- l4d2web/templates/admin.html | 13 +++++++++++ l4d2web/templates/admin_jobs.html | 27 ++++++++++++++++++++++ l4d2web/templates/admin_users.html | 19 ++++++++++++++++ l4d2web/tests/test_pages.py | 36 ++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 l4d2web/templates/admin.html create mode 100644 l4d2web/templates/admin_jobs.html create mode 100644 l4d2web/templates/admin_users.html diff --git a/l4d2web/routes/page_routes.py b/l4d2web/routes/page_routes.py index 75a4dab..a2b4917 100644 --- a/l4d2web/routes/page_routes.py +++ b/l4d2web/routes/page_routes.py @@ -6,7 +6,7 @@ from sqlalchemy import select from l4d2web.auth import current_user, require_admin, require_login from l4d2web.db import session_scope from l4d2web.models import Blueprint as BlueprintModel -from l4d2web.models import BlueprintOverlay, Job, Overlay, Server +from l4d2web.models import BlueprintOverlay, Job, Overlay, Server, User bp = Blueprint("pages", __name__) @@ -18,6 +18,33 @@ def dashboard() -> str: return render_template("dashboard.html") +@bp.get("/admin") +@require_admin +def admin_home() -> str: + return render_template("admin.html") + + +@bp.get("/admin/users") +@require_admin +def admin_users() -> str: + with session_scope() as db: + users = db.scalars(select(User).order_by(User.username)).all() + return render_template("admin_users.html", users=users) + + +@bp.get("/admin/jobs") +@require_admin +def admin_jobs() -> str: + with session_scope() as db: + rows = db.execute( + select(Job, User, Server) + .join(User, User.id == Job.user_id) + .outerjoin(Server, Server.id == Job.server_id) + .order_by(Job.created_at.desc()) + ).all() + return render_template("admin_jobs.html", rows=rows) + + @bp.get("/servers") @require_login def servers_page() -> str: diff --git a/l4d2web/templates/admin.html b/l4d2web/templates/admin.html new file mode 100644 index 0000000..bc9ffad --- /dev/null +++ b/l4d2web/templates/admin.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Admin | left4me{% endblock %} + +{% block content %} +
+

Admin

+ +
+{% endblock %} diff --git a/l4d2web/templates/admin_jobs.html b/l4d2web/templates/admin_jobs.html new file mode 100644 index 0000000..b954abf --- /dev/null +++ b/l4d2web/templates/admin_jobs.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Admin Jobs | left4me{% endblock %} + +{% block content %} +
+

Jobs

+ + + + {% for job, user, server in rows %} + + + + + + + + + + {% else %} + + {% endfor %} + +
IDOperationStateUserServerCreatedFinished
{{ job.id }}{{ job.operation }}{{ job.state }}{{ user.username }}{% if server %}{{ server.name }}{% else %}-{% endif %}{{ job.created_at }}{{ job.finished_at or "-" }}
No jobs found.
+
+{% endblock %} diff --git a/l4d2web/templates/admin_users.html b/l4d2web/templates/admin_users.html new file mode 100644 index 0000000..678fda9 --- /dev/null +++ b/l4d2web/templates/admin_users.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}Admin Users | left4me{% endblock %} + +{% block content %} +
+

Users

+ + + + {% for user in users %} + + {% else %} + + {% endfor %} + +
UsernameAdminCreatedUpdated
{{ user.username }}{{ "yes" if user.admin else "no" }}{{ user.created_at }}{{ user.updated_at }}
No users found.
+
+{% endblock %} diff --git a/l4d2web/tests/test_pages.py b/l4d2web/tests/test_pages.py index 045787b..ec30c2b 100644 --- a/l4d2web/tests/test_pages.py +++ b/l4d2web/tests/test_pages.py @@ -137,6 +137,42 @@ def test_servers_page_links_server_names(auth_client_with_server) -> None: assert ">details<" not in text +def test_non_admin_does_not_see_admin_nav(auth_client_with_server) -> None: + response = auth_client_with_server.get("/dashboard") + text = response.get_data(as_text=True) + + assert response.status_code == 200 + assert 'href="/admin"' not in text + + +def test_admin_can_use_admin_pages(tmp_path, monkeypatch) -> None: + db_url = f"sqlite:///{tmp_path/'admin-pages.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: + admin = User(username="admin", password_digest=hash_password("secret"), admin=True) + session.add(admin) + session.flush() + admin_id = admin.id + + client = app.test_client() + with client.session_transaction() as sess: + sess["user_id"] = admin_id + + assert client.get("/admin").status_code == 200 + assert client.get("/admin/users").status_code == 200 + assert client.get("/admin/jobs").status_code == 200 + assert 'href="/admin"' in client.get("/dashboard").get_data(as_text=True) + + +def test_non_admin_cannot_open_admin_pages(auth_client_with_server) -> None: + assert auth_client_with_server.get("/admin").status_code == 403 + assert auth_client_with_server.get("/admin/users").status_code == 403 + assert auth_client_with_server.get("/admin/jobs").status_code == 403 + + def test_blueprint_page_private_visibility(user_client_and_other_blueprint) -> None: client, blueprint_id = user_client_and_other_blueprint response = client.get(f"/blueprints/{blueprint_id}")