Files
sfera/services/api-server/src/api_server/html5_access.py
T
m 5f066d2f6b
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled
Show effective access roles in HTML5 workspace
2026-05-21 19:40:12 +03:00

409 lines
18 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.
from __future__ import annotations
from html import escape
import json
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>
{render_html5_access_profile_builder(project_id=project_id)}
</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 data-html5-access-user-detail>{render_html5_access_user_detail(project_id=project_id, user_payload=None)}</div>
<div class="access-list">{''.join(_user_card(project_id, 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_builder(*, project_id: str) -> str:
return f"""
<section class="access-builder" data-html5-access-builder>
<div class="panel-title">Новый профиль доступа</div>
<form class="access-builder-form">
<label>
<span>Имя профиля</span>
<input name="name" placeholder="ПрофильHTTP" />
</label>
<label>
<span>Объекты 1С</span>
<textarea name="target_objects" placeholder="HTTPСервис.ПубличныйAPI"></textarea>
</label>
<label>
<span>Права</span>
<input name="permissions" value="read" />
</label>
<label>
<span>Пользователь-источник</span>
<input name="source_user" placeholder="ivanov" />
</label>
<div class="access-builder-actions">
<button
type="submit"
hx-post="/html5/projects/{quote(project_id)}/access/profile-preview"
hx-target="[data-html5-access-builder-result]"
hx-swap="innerHTML"
>Предпросмотр</button>
<button
type="submit"
class="primary"
hx-post="/html5/projects/{quote(project_id)}/access/profiles"
hx-target="[data-html5-access-builder-result]"
hx-swap="innerHTML"
>Сохранить черновик</button>
</div>
</form>
<div data-html5-access-builder-result>
<p class="muted padded">Профиль будет построен сервером по ролям, правам и объектам 1С.</p>
</div>
</section>
"""
def render_html5_access_profile_preview(*, draft: object) -> str:
roles = list(getattr(draft, "roles", []) or [])
missing = list(getattr(draft, "missing_objects", []) or [])
warnings = list(getattr(draft, "warnings", []) or [])
proposed = dict(getattr(draft, "proposed_profile", {}) or {})
return f"""
<section class="access-builder-result" data-html5-access-builder-result-content>
<div class="access-plan-head">
<span class="status-pill">предпросмотр</span>
<strong>{escape(str(proposed.get("qualified_name") or getattr(draft, "name", "")))}</strong>
</div>
<div class="access-role-grid">{''.join(_role_card(role) for role in roles) or '<p class="muted padded">Роли не найдены</p>'}</div>
{_notice_list("Недостающие объекты", missing)}
{_notice_list("Предупреждения", warnings)}
</section>
"""
def render_html5_access_profile_apply_result(*, project_id: str, response: object, plan: object | None = None) -> str:
profile = getattr(response, "profile", {}) or {}
profile_name = str(profile.get("name") or profile.get("qualified_name") or "")
message = str(getattr(response, "message", ""))
return f"""
<section class="access-builder-result" data-html5-access-builder-result-content>
<div class="access-plan-head">
<span class="status-pill">сохранено</span>
<strong>{escape(profile_name)}</strong>
<a class="button" href="/html5/projects/{quote(project_id)}/access?profile={quote(profile_name)}">Открыть</a>
</div>
<p class="object-summary">{escape(message)}</p>
{render_html5_access_publish_plan(project_id=project_id, profile=_DictProfile(profile), plan=plan)}
</section>
"""
def render_html5_access_user_detail(*, project_id: str, user_payload: dict | None) -> str:
if user_payload is None:
return """
<section class="access-user-detail">
<p class="muted padded">Выберите пользователя, чтобы увидеть группы и эффективные роли.</p>
</section>
"""
user = dict(user_payload.get("user") or {})
roles = list(user_payload.get("effective_roles") or [])
groups = list(user.get("groups") or [])
name = str(user.get("name") or "")
full_name = str(user.get("full_name") or "")
return f"""
<section class="access-user-detail" data-html5-access-user="{escape(name)}">
<div class="access-plan-head">
<span class="status-pill">пользователь</span>
<strong>{escape(name)}</strong>
</div>
<p class="object-summary">{escape(full_name or "ФИО не загружено")}</p>
<div class="report-grid">
{_metric("Группы", len(groups))}
{_metric("Эффективные роли", len(roles))}
</div>
{_notice_list("Группы пользователя", groups)}
<div class="access-role-grid">{''.join(_role_card(_DictRole(item)) for item in roles) or '<p class="muted padded">Эффективные роли не найдены</p>'}</div>
</section>
"""
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(project_id: str, 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"
hx-get="/html5/projects/{quote(project_id)}/access/users/{quote(name, safe='')}"
hx-target="[data-html5-access-user-detail]"
hx-swap="innerHTML"
>
<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 _notice_list(title: str, values: list[object]) -> str:
if not values:
return ""
return f"""
<div class="panel-title">{escape(title)}</div>
<ul class="access-warnings">{''.join(f'<li>{escape(str(item))}</li>' for item in values)}</ul>
"""
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 "{}"
return json.dumps({key: value for key, value in list(payload.items())[:12]}, ensure_ascii=False, indent=2, default=str)
class _DictProfile:
def __init__(self, payload: dict):
self.name = str(payload.get("name") or "")
self.qualified_name = str(payload.get("qualified_name") or self.name)
self.roles = payload.get("roles") or []
self.attributes = payload.get("attributes") or {}
self.source = payload.get("source") or "workspace"
class _DictRole:
def __init__(self, payload: dict):
self.role = str(payload.get("role") or payload.get("name") or "")
self.role_qualified_name = str(payload.get("role_qualified_name") or payload.get("qualified_name") or self.role)
self.source = str(payload.get("source") or "")