Add HTML5 access profile workspace
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
from api_server.html5 import _metric, _page, _project_link, _topbar
|
||||
|
||||
|
||||
def render_html5_access_page(
|
||||
*,
|
||||
project_id: str,
|
||||
projects: Iterable[object],
|
||||
normalized: object | None,
|
||||
selected_profile: str | None = None,
|
||||
plan: object | None = None,
|
||||
dry_run: object | None = None,
|
||||
error: str | None = None,
|
||||
) -> str:
|
||||
project_nav = "\n".join(_project_link(project, project_id) for project in projects)
|
||||
if error or normalized is None:
|
||||
content = f"""
|
||||
<main class="workspace" data-html5-page="access" data-project-id="{escape(project_id)}">
|
||||
{_topbar(project_id, project_nav)}
|
||||
<section class="empty-state" data-html5-error>
|
||||
<h1>Права доступа не загружены</h1>
|
||||
<p>{escape(error or "NormalizedProject не найден")}</p>
|
||||
<a class="button" href="/html5/projects/{quote(project_id)}/setup">Открыть setup</a>
|
||||
</section>
|
||||
</main>
|
||||
"""
|
||||
return _page(f"SFERA Access - {project_id}", content)
|
||||
|
||||
access = getattr(normalized, "access", None)
|
||||
profiles = list(getattr(access, "profiles", []) or [])
|
||||
groups = list(getattr(access, "groups", []) or [])
|
||||
users = list(getattr(access, "users", []) or [])
|
||||
selected = _selected_profile(profiles, selected_profile)
|
||||
plan_html = render_html5_access_publish_plan(project_id=project_id, profile=selected, plan=plan)
|
||||
dry_run_html = render_html5_access_publish_result(project_id=project_id, result=dry_run)
|
||||
content = f"""
|
||||
<main class="workspace access-workspace" data-html5-page="access" data-project-id="{escape(project_id)}">
|
||||
{_topbar(project_id, project_nav)}
|
||||
<section class="access-layout">
|
||||
<aside class="panel access-nav" data-html5-access-nav>
|
||||
<div class="panel-title">Профили доступа</div>
|
||||
<dl class="metrics">
|
||||
{_metric("Профили", len(profiles))}
|
||||
{_metric("Группы", len(groups))}
|
||||
{_metric("Пользователи", len(users))}
|
||||
{_metric("Назначения", _assignment_count(profiles, groups, users))}
|
||||
</dl>
|
||||
<nav>{''.join(_profile_link(project_id, item, selected) for item in profiles) or '<p class="muted padded">Профили не найдены</p>'}</nav>
|
||||
</aside>
|
||||
<section class="editor access-main" data-html5-access-main>
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<p class="eyebrow">1C access model</p>
|
||||
<h1>{escape(_profile_name(selected) if selected is not None else "Права доступа")}</h1>
|
||||
</div>
|
||||
<a class="button" href="/html5/projects/{quote(project_id)}/editor">Редактор</a>
|
||||
</div>
|
||||
{render_html5_access_profile(project_id=project_id, profile=selected)}
|
||||
<div data-html5-access-plan>{plan_html}</div>
|
||||
<div data-html5-access-result>{dry_run_html}</div>
|
||||
</section>
|
||||
<aside class="panel access-side" data-html5-access-side>
|
||||
<div class="panel-title">Группы доступа</div>
|
||||
<div class="access-list">{''.join(_group_card(item) for item in groups[:80]) or '<p class="muted padded">Группы не найдены</p>'}</div>
|
||||
<div class="panel-title">Пользователи</div>
|
||||
<div class="access-list">{''.join(_user_card(item) for item in users[:80]) or '<p class="muted padded">Пользователи не найдены</p>'}</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
"""
|
||||
return _page(f"SFERA Access - {project_id}", content)
|
||||
|
||||
|
||||
def render_html5_access_profile(*, project_id: str, profile: object | None) -> str:
|
||||
if profile is None:
|
||||
return """
|
||||
<section class="access-empty">
|
||||
<strong>Выберите профиль доступа</strong>
|
||||
<span>План публикации и dry-run будут построены сервером по данным нормализованного объекта 1С.</span>
|
||||
</section>
|
||||
"""
|
||||
roles = list(getattr(profile, "roles", []) or [])
|
||||
attrs = dict(getattr(profile, "attributes", {}) or {})
|
||||
target_objects = list(attrs.get("target_objects") or [])
|
||||
permissions = list(attrs.get("permissions") or [])
|
||||
profile_name = _profile_name(profile)
|
||||
return f"""
|
||||
<section class="access-profile" data-html5-access-profile="{escape(profile_name)}">
|
||||
<div class="source-head">
|
||||
<div>
|
||||
<strong>{escape(profile_name)}</strong>
|
||||
<small>{escape(str(getattr(profile, "qualified_name", "") or ""))}</small>
|
||||
</div>
|
||||
<dl>
|
||||
{_metric("Роли", len(roles))}
|
||||
{_metric("Объекты", len(target_objects))}
|
||||
{_metric("Права", len(permissions))}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="access-summary">
|
||||
<span>{escape(str(getattr(profile, "source", "") or "workspace"))}</span>
|
||||
<span>{escape(str(attrs.get("status") or "loaded"))}</span>
|
||||
</div>
|
||||
<div class="access-role-grid">
|
||||
{''.join(_role_card(role) for role in roles) or '<p class="muted padded">Роли не назначены</p>'}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_access_publish_plan(*, project_id: str, profile: object | None, plan: object | None) -> str:
|
||||
if profile is None:
|
||||
return '<section class="access-plan"><div class="panel-title">План публикации</div><p class="muted padded">Нет выбранного профиля</p></section>'
|
||||
profile_name = _profile_name(profile)
|
||||
if plan is None:
|
||||
return f"""
|
||||
<section class="access-plan">
|
||||
<div class="panel-title">План публикации</div>
|
||||
<div class="access-actions">
|
||||
<button
|
||||
hx-get="/html5/projects/{quote(project_id)}/access/profiles/{quote(profile_name, safe='')}/plan"
|
||||
hx-target="[data-html5-access-plan]"
|
||||
hx-swap="innerHTML"
|
||||
>Построить план</button>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
operations = list(getattr(plan, "operations", []) or [])
|
||||
warnings = list(getattr(plan, "warnings", []) or [])
|
||||
ready = bool(getattr(plan, "ready_for_extension", False))
|
||||
warning_html = "".join(f"<li>{escape(str(item))}</li>" for item in warnings)
|
||||
dry_run_button = (
|
||||
f"""
|
||||
<form
|
||||
hx-post="/html5/projects/{quote(project_id)}/access/profiles/{quote(profile_name, safe='')}/publish-dry-run"
|
||||
hx-target="[data-html5-access-result]"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<button type="submit" class="primary">Dry-run в 1С</button>
|
||||
</form>
|
||||
"""
|
||||
if ready
|
||||
else '<p class="muted padded">План не готов к отправке в расширение</p>'
|
||||
)
|
||||
return f"""
|
||||
<section class="access-plan">
|
||||
<div class="panel-title">План публикации</div>
|
||||
<div class="access-plan-head">
|
||||
<span class="status-pill">{'готов' if ready else 'требует проверки'}</span>
|
||||
<strong>{len(operations)} операций</strong>
|
||||
{dry_run_button}
|
||||
</div>
|
||||
<ul class="access-warnings">{warning_html}</ul>
|
||||
<div class="access-operations">{''.join(_operation_card(item) for item in operations) or '<p class="muted padded">Операций нет</p>'}</div>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def render_html5_access_publish_result(*, project_id: str, result: object | None) -> str:
|
||||
if result is None:
|
||||
return '<section class="access-result"><div class="panel-title">Ответ расширения</div><p class="muted padded">Dry-run еще не выполнялся</p></section>'
|
||||
checks = list(getattr(result, "checks", []) or [])
|
||||
payload = dict(getattr(result, "result", {}) or {})
|
||||
status = str(getattr(result, "status", ""))
|
||||
ready = bool(getattr(result, "ready", False))
|
||||
return f"""
|
||||
<section class="access-result" data-html5-access-result-status="{escape(status)}">
|
||||
<div class="panel-title">Ответ расширения</div>
|
||||
<div class="access-plan-head">
|
||||
<span class="status-pill">{escape(status)}</span>
|
||||
<strong>{'расширение ответило' if ready else 'требуется настройка публикации'}</strong>
|
||||
</div>
|
||||
<div class="access-operations">{''.join(_check_card(item) for item in checks)}</div>
|
||||
<pre class="access-json">{escape(_short_json(payload))}</pre>
|
||||
</section>
|
||||
"""
|
||||
|
||||
|
||||
def _selected_profile(profiles: list[object], selected_profile: str | None) -> object | None:
|
||||
if not profiles:
|
||||
return None
|
||||
if not selected_profile:
|
||||
return profiles[0]
|
||||
wanted = selected_profile.casefold()
|
||||
return next(
|
||||
(
|
||||
item
|
||||
for item in profiles
|
||||
if _profile_name(item).casefold() == wanted
|
||||
or str(getattr(item, "qualified_name", "") or "").casefold() == wanted
|
||||
),
|
||||
profiles[0],
|
||||
)
|
||||
|
||||
|
||||
def _profile_link(project_id: str, profile: object, selected: object | None) -> str:
|
||||
name = _profile_name(profile)
|
||||
active = selected is profile
|
||||
roles = list(getattr(profile, "roles", []) or [])
|
||||
return f"""
|
||||
<a class="tree-item" data-html5-access-profile-selected="{str(active).lower()}" href="/html5/projects/{quote(project_id)}/access?profile={quote(name)}">
|
||||
<span>{escape(name)}</span>
|
||||
<small>{len(roles)} ролей</small>
|
||||
</a>
|
||||
"""
|
||||
|
||||
|
||||
def _profile_name(profile: object) -> str:
|
||||
return str(getattr(profile, "name", None) or getattr(profile, "qualified_name", None) or "Профиль")
|
||||
|
||||
|
||||
def _role_card(role: object) -> str:
|
||||
name = str(getattr(role, "role_qualified_name", None) or getattr(role, "role", None) or role)
|
||||
source = str(getattr(role, "source", "") or "")
|
||||
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(source)}</small></article>'
|
||||
|
||||
|
||||
def _group_card(group: object) -> str:
|
||||
name = str(getattr(group, "name", ""))
|
||||
profile = str(getattr(group, "profile_qualified_name", None) or getattr(group, "profile", None) or "без профиля")
|
||||
users = list(getattr(group, "users", []) or [])
|
||||
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(profile)} · {len(users)} пользователей</small></article>'
|
||||
|
||||
|
||||
def _user_card(user: object) -> str:
|
||||
name = str(getattr(user, "name", ""))
|
||||
full_name = str(getattr(user, "full_name", "") or "")
|
||||
groups = list(getattr(user, "groups", []) or [])
|
||||
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(full_name)} · {len(groups)} групп</small></article>'
|
||||
|
||||
|
||||
def _operation_card(operation: dict) -> str:
|
||||
action = str(operation.get("action", "operation"))
|
||||
target = str(operation.get("target", ""))
|
||||
detail = str(operation.get("role") or operation.get("profile") or operation.get("name") or "")
|
||||
return f'<article class="access-card"><strong>{escape(action)}</strong><small>{escape(target)} {escape(detail)}</small></article>'
|
||||
|
||||
|
||||
def _check_card(check: object) -> str:
|
||||
code = str(getattr(check, "code", ""))
|
||||
status = str(getattr(check, "status", ""))
|
||||
message = str(getattr(check, "message", ""))
|
||||
return f'<article class="access-card"><strong>{escape(code)} · {escape(status)}</strong><small>{escape(message)}</small></article>'
|
||||
|
||||
|
||||
def _assignment_count(profiles: list[object], groups: list[object], users: list[object]) -> int:
|
||||
return (
|
||||
sum(len(getattr(item, "roles", []) or []) for item in profiles)
|
||||
+ sum(len(getattr(item, "roles", []) or []) + len(getattr(item, "users", []) or []) for item in groups)
|
||||
+ sum(len(getattr(item, "roles", []) or []) + len(getattr(item, "groups", []) or []) for item in users)
|
||||
)
|
||||
|
||||
|
||||
def _short_json(payload: dict) -> str:
|
||||
if not payload:
|
||||
return "{}"
|
||||
items = [f"{key}: {value}" for key, value in list(payload.items())[:12]]
|
||||
return "\n".join(items)
|
||||
Reference in New Issue
Block a user