v1.4.0: модули с веб-UI, правое меню, расширенные тесты

Реестр MODULE_MOUNTS: API, ui_router, фрагменты главной, EventBus.
Главная и страницы модулей с правой навигацией из реестра; wrap_module_html_page.
Ingress: публикация alert.received после сохранения в БД.
Документация MODULES.md; pytest покрывает API, UI и навигацию.

Made-with: Cursor
This commit is contained in:
Alexandr
2026-04-03 08:45:19 +03:00
parent 85eb61b576
commit 349cea85a3
17 changed files with 571 additions and 24 deletions

View File

@ -0,0 +1,107 @@
"""Безопасная сборка HTML с модулей и общий каркас UI (правая колонка меню из реестра)."""
from __future__ import annotations
import html
import logging
from collections.abc import Awaitable, Callable
from starlette.requests import Request
log = logging.getLogger("onguard24.modules.ui")
# Общие стили: сетка «контент слева + меню справа», навигация по модулям
APP_SHELL_CSS = """
body { font-family: system-ui, sans-serif; margin: 0; background: #fafafa; color: #18181b; }
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
.app-shell { display: flex; flex-direction: row; align-items: flex-start; gap: 1.5rem;
max-width: 72rem; margin: 0 auto; padding: 1.5rem 1.25rem 2rem; box-sizing: border-box; }
.app-main { flex: 1; min-width: 0; }
.app-rail { width: 13.5rem; flex-shrink: 0; position: sticky; top: 1rem;
background: #fff; border-radius: 8px; box-shadow: 0 1px 3px #0001; padding: 0.75rem 0; }
.rail-title { margin: 0 0 0.5rem; padding: 0 0.75rem; font-size: 0.75rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; color: #71717a; }
.rail-nav ul { list-style: none; margin: 0; padding: 0; }
.rail-item a { display: block; padding: 0.45rem 0.75rem; font-size: 0.9rem; color: #3f3f46; border-radius: 4px; }
.rail-item a:hover { background: #f4f4f5; }
.rail-item.is-active a { background: #eff6ff; color: #1d4ed8; font-weight: 600; }
.module-page-main h1 { margin-top: 0; font-size: 1.35rem; }
"""
def nav_rail_html(current_slug: str | None = None) -> str:
"""Правая колонка: пункты из реестра модулей с `ui_router` + «Главная».
Новый модуль в `MODULE_MOUNTS` с UI — пункт появляется автоматически.
"""
from onguard24.modules.registry import MODULE_MOUNTS
home_li = (
'<li class="rail-item'
+ (" is-active" if current_slug is None else "")
+ '"><a href="/">Главная</a></li>'
)
items: list[str] = [home_li]
for m in MODULE_MOUNTS:
if m.ui_router is None:
continue
href = f"/ui/modules/{m.slug}/"
active = m.slug == current_slug
licls = "rail-item" + (" is-active" if active else "")
cur = ' aria-current="page"' if active else ""
items.append(
f'<li class="{licls}"><a href="{html.escape(href)}"{cur}>{html.escape(m.title)}</a></li>'
)
lis = "".join(items)
return (
'<aside class="app-rail" role="navigation" aria-label="Разделы приложения">'
'<nav class="rail-nav">'
'<h2 class="rail-title">Разделы</h2>'
f"<ul>{lis}</ul>"
"</nav>"
"</aside>"
)
def wrap_module_html_page(
*,
document_title: str,
current_slug: str,
main_inner_html: str,
) -> str:
"""Полная HTML-страница модуля: основной контент + то же правое меню, что и на главной."""
rail = nav_rail_html(current_slug)
return f"""<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{html.escape(document_title)}</title>
<style>{APP_SHELL_CSS}</style>
</head>
<body>
<div class="app-shell">
<main class="app-main module-page-main">
{main_inner_html}
</main>
{rail}
</div>
</body>
</html>"""
async def safe_fragment(
module_slug: str,
fn: Callable[[Request], Awaitable[str]],
request: Request,
) -> str:
try:
return await fn(request)
except Exception:
log.exception("module %s: ошибка фрагмента главной страницы", module_slug)
return (
'<aside class="module-err" role="alert">'
f"Модуль «{html.escape(module_slug)}»: блок временно недоступен."
"</aside>"
)