409 lines
18 KiB
Python
409 lines
18 KiB
Python
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 "")
|