Files
appfactory-portal/app/templates/layout.py
T
2026-08-03 12:04:13 +02:00

174 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import html
import unicodedata
from pathlib import Path
from ..config import PORTAL_PREFIX
APP_NAME = "CSBot Services Portal"
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
def _asset_version(filename: str) -> str:
"""Otisk statického souboru do URL (?v=...).
Starlette u statiky neposílá Cache-Control, takže prohlížeč umí po nasazení držet starý
styles.css/portal.js a nová stránka pak vypadá rozbitě. Otisk se počítá při startu
aplikace - po redeployi kontejneru se změní a prohlížeč si soubor stáhne znovu.
"""
try:
return str(int((STATIC_DIR / filename).stat().st_mtime))
except OSError:
return "0"
STYLES_VERSION = _asset_version("styles.css")
SCRIPT_VERSION = _asset_version("portal.js")
# Sekce viditelné pouze pro tento e-mail (Migration Readiness, Runtime Management, Environment).
SUPER_ADMIN_EMAIL = "jiri.uhlir59@gmail.com"
# Admin menu: (popisek, odkaz, FA ikona, super_only). super_only = jen pro SUPER_ADMIN_EMAIL.
ADMIN_MENU_ITEMS = [
("Alerting", "/portal/alerting/rules", "fa-bell", False),
("Audit", "/portal/audit", "fa-clipboard-list", False),
("Environment", "/portal/admin/environment", "fa-sliders", True),
("Incidenty", "/portal/incidents", "fa-triangle-exclamation", False),
("IP Access", "/portal/admin/ip-access", "fa-shield-halved", False),
("Nasazení", "/portal/deployments", "fa-rocket", False),
("Plánované skripty", "/portal/scheduled-scripts", "fa-calendar-days", False),
("Služby", "/portal/apps", "fa-server", False),
("Úlohy", "/portal/jobs", "fa-list-check", False),
("Users", "/portal/admin/users", "fa-users", False),
("Workery", "/portal/workers", "fa-gears", False),
("Zálohy", "/portal/backups", "fa-box-archive", False),
]
def _alpha_key(label: str) -> str:
"""Řadicí klíč bez diakritiky (Plánované < Přehled < Služby < Úlohy ...)."""
normalized = unicodedata.normalize("NFKD", label)
return "".join(ch for ch in normalized if not unicodedata.combining(ch)).lower()
def page(title: str, body: str, user=None) -> str:
nav = ""
user_panel = ""
if user:
username = html.escape(user.get("username", ""))
display_name = html.escape(user.get("display_name") or user.get("username", ""))
role = (user.get("role") or "").lower()
email = (user.get("email") or "").strip().lower()
can_operate = role in ("admin", "developer") # provozní sekce: jen dev nebo admin
is_super = email == SUPER_ADMIN_EMAIL
# Guest nemá přístup nikam nezobrazujeme mu žádné odkazy, jen odhlášení.
is_guest = role == "guest"
# Hlavní navigace: Služby vidí každý přihlášený uživatel (kromě guesta); Úlohy, Plánované
# skripty, Alerting a Pro vývojáře jen developer nebo admin.
nav_links = ""
if not is_guest:
nav_links = '<a class="nav-link" href="/portal/apps"><i class="fa-solid fa-server" aria-hidden="true"></i> Služby</a>'
if can_operate:
nav_links += (
'<a class="nav-link" href="/portal/jobs"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy</a>'
'<a class="nav-link" href="/portal/logs"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Logy</a>'
'<a class="nav-link" href="/portal/scheduled-scripts"><i class="fa-solid fa-calendar-days" aria-hidden="true"></i> Plánované skripty</a>'
'<a class="nav-link" href="/portal/alerting/rules"><i class="fa-solid fa-bell" aria-hidden="true"></i> Alerting</a>'
'<a class="nav-link" href="/portal/developers"><i class="fa-solid fa-code" aria-hidden="true"></i> Pro vývojáře</a>'
)
# IP Access spravují i developeři, ale admin menu se jim nezobrazuje proto jen jim
# přidáváme odkaz do hlavní navigace, aby stránku nemuseli hledat přes detail služby.
if can_operate and role != "admin":
nav_links += '<a class="nav-link" href="/portal/admin/ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> IP Access</a>'
admin_menu = ""
if role == "admin":
visible_items = [item for item in ADMIN_MENU_ITEMS if not item[3] or is_super]
visible_items.sort(key=lambda item: _alpha_key(item[0]))
admin_links = "".join(
f'<a href="{href}"><i class="fa-solid {icon}" aria-hidden="true"></i> {html.escape(label)}</a>'
for label, href, icon, _super_only in visible_items
)
admin_menu = f"""
<details class="nav-menu">
<summary class="nav-menu-button"><i class="fa-solid fa-screwdriver-wrench" aria-hidden="true"></i> Admin</summary>
<div class="nav-menu-panel">
{admin_links}
</div>
</details>
"""
nav = f"""
<button type="button" class="nav-toggle" aria-label="Otevřít menu" onclick="togglePortalNav()"><i class="fa-solid fa-bars" aria-hidden="true"></i></button>
<div class="nav-wrap" id="portal-nav">
<nav>
{nav_links}
{admin_menu}
<div class="user-menu">
<span class="user-name">{display_name}</span>
<span class="muted">@{username}</span>
<form method="post" action="/portal/logout">
<button type="submit" class="btn-secondary"><i class="fa-solid fa-right-from-bracket" aria-hidden="true"></i> Odhlásit</button>
</form>
</div>
</nav>
</div>
"""
user_panel = ""
return f"""
<html>
<head>
<title>{APP_NAME} - {html.escape(title)}</title>
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/vendor/fontawesome/css/fontawesome.min.css">
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/vendor/fontawesome/css/solid.min.css">
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css?v={STYLES_VERSION}">
<script src="{PORTAL_PREFIX}/static/portal.js?v={SCRIPT_VERSION}"></script>
</head>
<body>
<header>
<a class="brand" href="/portal/apps" aria-label="{APP_NAME}">
<img src="{PORTAL_PREFIX}/static/csbot-logo.svg" alt="CSBOT">
<span>{APP_NAME}</span>
</a>
{nav}
{user_panel}
</header>
<main>
{body}
</main>
</body>
</html>
"""
def render_result(title, back_url, sections, extra_link=None, extra_label=None, user=None):
rendered_sections = ""
for section_title, content in sections:
rendered_sections += f"""
<h2>{html.escape(section_title)}</h2>
<pre>{html.escape(content)}</pre>
"""
extra = ""
if extra_link and extra_label:
extra = f'<p><a class="btn" href="{extra_link}">{html.escape(extra_label)}</a></p>'
return page(
title,
f"""
<div class="card">
<h2>{html.escape(title)}</h2>
{extra}
<p><a href="{back_url}">&larr; Zpět</a></p>
</div>
<div class="card">
{rendered_sections}
</div>
""",
user=user,
)