From 2f7db03001a22be6aac76b8e955a4200e7d923ed Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 19:29:35 +0300 Subject: [PATCH] Add HTML5 access profile workspace --- services/api-server/src/api_server/html5.py | 1 + .../api-server/src/api_server/html5_access.py | 263 ++++++++++++++++++ .../src/api_server/html5_access_controller.py | 84 ++++++ services/api-server/src/api_server/main.py | 50 ++++ .../src/api_server/static/html5/html5.css | 2 + services/api-server/tests/test_api.py | 20 ++ 6 files changed, 420 insertions(+) create mode 100644 services/api-server/src/api_server/html5_access.py create mode 100644 services/api-server/src/api_server/html5_access_controller.py diff --git a/services/api-server/src/api_server/html5.py b/services/api-server/src/api_server/html5.py index 41388a4..0341f93 100644 --- a/services/api-server/src/api_server/html5.py +++ b/services/api-server/src/api_server/html5.py @@ -45,6 +45,7 @@ def _topbar(project_id: str, project_nav: str) -> str: API Операции + Права HTML5 Setup Legacy Next """ diff --git a/services/api-server/src/api_server/html5_access.py b/services/api-server/src/api_server/html5_access.py new file mode 100644 index 0000000..77f1214 --- /dev/null +++ b/services/api-server/src/api_server/html5_access.py @@ -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""" +
+ {_topbar(project_id, project_nav)} +
+

Права доступа не загружены

+

{escape(error or "NormalizedProject не найден")}

+ Открыть setup +
+
+ """ + 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""" +
+ {_topbar(project_id, project_nav)} +
+ +
+
+
+

1C access model

+

{escape(_profile_name(selected) if selected is not None else "Права доступа")}

+
+ Редактор +
+ {render_html5_access_profile(project_id=project_id, profile=selected)} +
{plan_html}
+
{dry_run_html}
+
+ +
+
+ """ + 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 """ +
+ Выберите профиль доступа + План публикации и dry-run будут построены сервером по данным нормализованного объекта 1С. +
+ """ + 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""" +
+
+
+ {escape(profile_name)} + {escape(str(getattr(profile, "qualified_name", "") or ""))} +
+
+ {_metric("Роли", len(roles))} + {_metric("Объекты", len(target_objects))} + {_metric("Права", len(permissions))} +
+
+
+ {escape(str(getattr(profile, "source", "") or "workspace"))} + {escape(str(attrs.get("status") or "loaded"))} +
+
+ {''.join(_role_card(role) for role in roles) or '

Роли не назначены

'} +
+
+ """ + + +def render_html5_access_publish_plan(*, project_id: str, profile: object | None, plan: object | None) -> str: + if profile is None: + return '
План публикации

Нет выбранного профиля

' + profile_name = _profile_name(profile) + if plan is None: + return f""" +
+
План публикации
+
+ +
+
+ """ + operations = list(getattr(plan, "operations", []) or []) + warnings = list(getattr(plan, "warnings", []) or []) + ready = bool(getattr(plan, "ready_for_extension", False)) + warning_html = "".join(f"
  • {escape(str(item))}
  • " for item in warnings) + dry_run_button = ( + f""" +
    + +
    + """ + if ready + else '

    План не готов к отправке в расширение

    ' + ) + return f""" +
    +
    План публикации
    +
    + {'готов' if ready else 'требует проверки'} + {len(operations)} операций + {dry_run_button} +
    + +
    {''.join(_operation_card(item) for item in operations) or '

    Операций нет

    '}
    +
    + """ + + +def render_html5_access_publish_result(*, project_id: str, result: object | None) -> str: + if result is None: + return '
    Ответ расширения

    Dry-run еще не выполнялся

    ' + 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""" +
    +
    Ответ расширения
    +
    + {escape(status)} + {'расширение ответило' if ready else 'требуется настройка публикации'} +
    +
    {''.join(_check_card(item) for item in checks)}
    +
    {escape(_short_json(payload))}
    +
    + """ + + +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""" + + {escape(name)} + {len(roles)} ролей + + """ + + +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'
    {escape(name)}{escape(source)}
    ' + + +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'
    {escape(name)}{escape(profile)} · {len(users)} пользователей
    ' + + +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'
    {escape(name)}{escape(full_name)} · {len(groups)} групп
    ' + + +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'
    {escape(action)}{escape(target)} {escape(detail)}
    ' + + +def _check_card(check: object) -> str: + code = str(getattr(check, "code", "")) + status = str(getattr(check, "status", "")) + message = str(getattr(check, "message", "")) + return f'
    {escape(code)} · {escape(status)}{escape(message)}
    ' + + +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) diff --git a/services/api-server/src/api_server/html5_access_controller.py b/services/api-server/src/api_server/html5_access_controller.py new file mode 100644 index 0000000..c75cf1b --- /dev/null +++ b/services/api-server/src/api_server/html5_access_controller.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Iterable +from typing import Any + +from fastapi import HTTPException + +from api_server.html5_access import ( + render_html5_access_page, + render_html5_access_publish_plan, + render_html5_access_publish_result, +) + + +def html5_access_page( + *, + project_id: str, + profile: str | None, + project_summaries: Callable[[], Iterable[object]], + normalized_project: Callable[[str], object], + access_profile_by_name: Callable[[object, str], object | None], + access_publish_plan: Callable[[object, object], object], +) -> str: + try: + normalized = normalized_project(project_id) + selected = _selected_profile(normalized, profile, access_profile_by_name) + plan = access_publish_plan(normalized, selected) if selected is not None else None + return render_html5_access_page( + project_id=project_id, + projects=project_summaries(), + normalized=normalized, + selected_profile=profile, + plan=plan, + ) + except HTTPException as error: + return render_html5_access_page( + project_id=project_id, + projects=project_summaries(), + normalized=None, + error=str(error.detail), + ) + + +def html5_access_publish_plan( + *, + project_id: str, + profile_name: str, + normalized_project: Callable[[str], object], + access_profile_by_name: Callable[[object, str], object | None], + access_publish_plan: Callable[[object, object], object], +) -> str: + normalized = normalized_project(project_id) + profile = access_profile_by_name(normalized, profile_name) + if profile is None: + raise HTTPException(status_code=404, detail="Access profile not found") + return render_html5_access_publish_plan( + project_id=project_id, + profile=profile, + plan=access_publish_plan(normalized, profile), + ) + + +async def html5_access_publish_dry_run( + *, + project_id: str, + profile_name: str, + publish_dry_run: Callable[[str, str], Awaitable[Any]], +) -> str: + result = await publish_dry_run(project_id, profile_name) + return render_html5_access_publish_result(project_id=project_id, result=result) + + +def _selected_profile( + normalized: object, + profile_name: str | None, + access_profile_by_name: Callable[[object, str], object | None], +) -> object | None: + access = getattr(normalized, "access", None) + profiles = list(getattr(access, "profiles", []) or []) + if not profiles: + return None + if not profile_name: + return profiles[0] + return access_profile_by_name(normalized, profile_name) or profiles[0] diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 56788b7..4d02fdd 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -55,6 +55,11 @@ from api_server.agent_models import ( from api_server.html5_forms import ( html5_form_data as _html5_form_data, ) +from api_server.html5_access_controller import ( + html5_access_page as _html5_access_page, + html5_access_publish_dry_run as _html5_access_publish_dry_run, + html5_access_publish_plan as _html5_access_publish_plan, +) from api_server.html5_editor_controller import ( html5_editor_page as _html5_editor_page, html5_form_editor_page as _html5_form_editor_page, @@ -1532,6 +1537,44 @@ async def html5_project_form_editor(project_id: str, form: str | None = None) -> ) +@app.get("/html5/projects/{project_id}/access") +async def html5_project_access(project_id: str, profile: str | None = None) -> Response: + return _html5_response( + _html5_access_page( + project_id=project_id, + profile=profile, + project_summaries=_project_summaries, + normalized_project=_normalized_project_or_404, + access_profile_by_name=_access_profile_by_name, + access_publish_plan=_build_access_profile_publish_plan, + ) + ) + + +@app.get("/html5/projects/{project_id}/access/profiles/{profile_name}/plan") +async def html5_project_access_profile_plan(project_id: str, profile_name: str) -> Response: + return _html5_response( + _html5_access_publish_plan( + project_id=project_id, + profile_name=profile_name, + normalized_project=_normalized_project_or_404, + access_profile_by_name=_access_profile_by_name, + access_publish_plan=_build_access_profile_publish_plan, + ) + ) + + +@app.post("/html5/projects/{project_id}/access/profiles/{profile_name}/publish-dry-run") +async def html5_project_access_profile_publish_dry_run(project_id: str, profile_name: str) -> Response: + return _html5_response( + await _html5_access_publish_dry_run( + project_id=project_id, + profile_name=profile_name, + publish_dry_run=dry_run_project_access_profile_publish, + ) + ) + + @app.post("/html5/projects/{project_id}/forms/editor/preview") async def html5_project_form_editor_preview(project_id: str, request: Request) -> Response: form = await _html5_form_data(request) @@ -2927,6 +2970,13 @@ async def get_normalized_project(project_id: str) -> NormalizedProject: return normalized +def _normalized_project_or_404(project_id: str) -> NormalizedProject: + normalized = _load_normalized_project(project_id) + if normalized is None: + raise HTTPException(status_code=404, detail="NormalizedProject not found") + return normalized + + @app.get("/projects/{project_id}/normalized/summary", response_model=NormalizedProjectSummary) async def get_normalized_project_summary(project_id: str) -> NormalizedProjectSummary: normalized = _load_normalized_project(project_id) diff --git a/services/api-server/src/api_server/static/html5/html5.css b/services/api-server/src/api_server/static/html5/html5.css index f7c50bb..237c122 100644 --- a/services/api-server/src/api_server/static/html5/html5.css +++ b/services/api-server/src/api_server/static/html5/html5.css @@ -10,6 +10,8 @@ .form-editor-layout{grid-template-columns:280px minmax(0,1.2fr) minmax(320px,.8fr)}.form-editor-actions{display:flex;gap:8px;flex-wrap:wrap}.tree-item[data-html5-form-selected="true"]{background:#f8fbff;border-left-color:var(--brand)}.form-designer{height:calc(100% - 72px);display:grid;grid-template-rows:auto minmax(0,1fr) auto;overflow:hidden;background:#f7f9fc}.form-designer-head,.form-designer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line);background:#fff}.form-designer-head strong,.form-designer-head small{display:block}.form-designer-head small{color:var(--muted)}.form-designer-head span{border:1px solid var(--line);background:#eef6f1;color:var(--ok);padding:5px 8px;font-size:12px;font-weight:900}.form-designer-foot{border-top:1px solid var(--line);border-bottom:0}.form-canvas{min-height:0;overflow:auto;padding:18px}.form-window{max-width:720px;min-height:420px;margin:0 auto;border:1px solid var(--line);background:#fff;box-shadow:0 8px 24px rgba(15,23,42,.08)}.form-window-title{height:38px;display:flex;align-items:center;border-bottom:1px solid var(--line);padding:0 12px;font-weight:900;background:#fbfcfe}.form-command-bar{display:flex;gap:6px;flex-wrap:wrap;padding:10px 12px;border-bottom:1px solid var(--line)}.form-command-bar button{height:28px;font-size:12px}.form-fields{display:grid;gap:10px;padding:14px}.form-field{display:grid;grid-template-columns:160px minmax(0,1fr);gap:8px;align-items:center}.form-field span{font-size:12px;font-weight:800;color:var(--muted)}.form-field input{height:30px;border:1px solid var(--line);padding:0 8px;background:#fbfaf7} .form-designer-body{min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 340px;overflow:hidden}.form-property-panel{min-height:0;overflow:auto;border-left:1px solid var(--line);background:#fff}.property-row{display:grid;grid-template-columns:1fr 132px;gap:8px;padding:12px;border-bottom:1px solid var(--line)}.property-row label,.form-editor-row label{display:grid;gap:4px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.property-row input,.property-row select,.form-editor-row input,.form-editor-row select,.form-add-row input,.form-add-row select{height:30px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.2 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.form-window{border-color:#b9c1cd;background:#fdfdfd;box-shadow:0 12px 28px rgba(33,43,54,.12)}.form-window-title{justify-content:space-between;height:34px;background:linear-gradient(#f8f9fb,#e9edf3);border-bottom-color:#cbd3df;color:#1f2937}.form-window-title small{font-size:11px;color:#687385;font-weight:800}.form-command-bar{min-height:42px;background:#f4f6f9;border-bottom-color:#ccd4df}.form-command-bar button{height:27px;border-color:#aeb8c6;background:linear-gradient(#fff,#edf1f6);box-shadow:inset 0 1px 0 #fff;color:#1f2937}.form-command-bar button:hover{background:#fff}.form-fields{grid-template-columns:repeat(12,minmax(0,1fr));align-content:start;gap:8px 12px;padding:14px 16px 22px;background:#fbfbfc}.form-field{grid-column:1/-1;grid-template-columns:150px minmax(0,1fr);min-height:32px;padding:4px 6px;border:1px solid transparent}.form-field:hover{border-color:#b9c1cd;background:#fff}.form-field[data-html5-form-width="half"]{grid-column:span 6}.form-field[data-html5-form-width="third"]{grid-column:span 4}.form-field>label{font-size:12px;font-weight:800;color:#4b5563;align-self:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.form-input-control,.form-text-control{width:100%;border:1px solid #aeb8c6;background:#fff;padding:0 7px;color:#17202a;box-shadow:inset 0 1px 2px rgba(15,23,42,.06)}.form-input-control{height:28px}.form-text-control{min-height:64px;resize:none}.form-check-control{display:flex;align-items:center;gap:8px;color:#374151}.form-check-control input{width:16px;height:16px}.form-table-control{border:1px solid #aeb8c6;background:#fff;min-height:108px;overflow:hidden}.form-table-control div{display:grid;grid-template-columns:2fr 1fr 1fr}.form-table-control span{min-height:27px;border-right:1px solid #d7dde6;border-bottom:1px solid #d7dde6;padding:5px 7px;color:#374151;font-size:12px}.form-table-control div:first-child span{background:#eef2f7;font-weight:900}.form-group-control{min-height:44px;border:1px dashed #aeb8c6;background:#f6f8fb;padding:10px;color:#687385;font-weight:800}.form-editor-row{display:grid;gap:7px;padding:10px 12px;border-bottom:1px solid var(--line)}label.form-editor-row{grid-template-columns:88px minmax(0,1fr);align-items:center;text-transform:none}.form-editor-row small{color:var(--muted);font-size:11px}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr 118px 1fr 104px}.form-add-row{display:grid;grid-template-columns:minmax(0,1fr) 118px;gap:8px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.button.primary{background:var(--brand);border-color:var(--brand);color:#fff;margin:12px}.form-designer-foot small{color:var(--muted);font-weight:800}.form-window[data-html5-form-layout="compact"] .form-fields{gap:4px 8px}.form-window[data-html5-form-layout="compact"] .form-field{min-height:28px}.form-window[data-html5-form-layout="columns"] .form-field{grid-column:span 6}.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="table"],.form-window[data-html5-form-layout="columns"] .form-field[data-html5-form-control="group"]{grid-column:1/-1} .form-empty-structure{grid-column:1/-1;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.form-empty-structure strong,.form-empty-structure span{display:block}.form-empty-structure strong{color:#1f2937} +.access-workspace{background:#f4f7fb}.access-layout{display:grid;grid-template-columns:300px minmax(0,1fr)340px;height:calc(100vh - 88px)}.access-main{border-top:0;border-bottom:0;overflow:auto}.access-nav,.access-side{overflow:auto}.tree-item[data-html5-access-profile-selected="true"]{background:#f8fbff;border-left:3px solid var(--brand);padding-left:9px}.access-empty{margin:16px;border:1px dashed #aeb8c6;background:#fff;padding:18px;color:#687385}.access-empty strong,.access-empty span{display:block}.access-empty strong{color:#1f2937}.access-profile,.access-plan,.access-result{border-bottom:1px solid var(--line);background:#fff}.access-summary{display:flex;gap:8px;flex-wrap:wrap;padding:10px 14px;border-bottom:1px solid var(--line);background:#f8fbff;color:#687385;font-size:12px;font-weight:800}.access-role-grid,.access-operations,.access-list{display:grid}.access-role-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.access-card{display:grid;gap:3px;min-width:0;padding:10px 12px;border-bottom:1px solid var(--line)}.access-role-grid .access-card:nth-child(odd){border-right:1px solid var(--line)}.access-card strong,.access-card small{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.access-card small{color:var(--muted)}.access-plan-head{display:flex;gap:10px;align-items:center;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-plan-head form{margin:0}.access-actions{padding:12px}.access-warnings{margin:0;padding:0;list-style:none}.access-warnings li{padding:8px 12px;border-bottom:1px solid var(--line);color:var(--warn);font-weight:800}.access-json{margin:0;max-height:220px;overflow:auto;padding:12px;background:#fbfaf7;border-top:1px solid var(--line);font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap}.access-main .primary{background:var(--brand);border-color:var(--brand);color:#fff} @media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}} +@media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}} @media(max-width:980px){.form-designer-body{grid-template-columns:1fr}.form-property-panel{border-left:0;border-top:1px solid var(--line)}.form-field[data-html5-form-width="half"],.form-field[data-html5-form-width="third"]{grid-column:1/-1}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr}.property-row{grid-template-columns:1fr}} @media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}} diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 0da4721..2bb0f14 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1610,6 +1610,26 @@ def test_import_supports_structure_only_indexing(monkeypatch, tmp_path: Path): assert captured_extension_call["request"].dry_run is True assert captured_extension_call["request"].payload["profile"]["roles"] == ["Роль.Менеджер"] + access_page = client.get(f"/html5/projects/{project_id}/access", params={"profile": "НовыйПрофильHTTP"}) + assert_html5_response_contract( + access_page, + 'data-html5-page="access"', + "НовыйПрофильHTTP", + "План публикации", + "Dry-run в 1С", + full_page=True, + ) + + access_plan = client.get( + f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan" + ) + assert_html5_response_contract(access_plan, "CREATE_ACCESS_PROFILE", "ADD_ROLE_TO_PROFILE") + + access_dry_run = client.post( + f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/publish-dry-run" + ) + assert_html5_response_contract(access_dry_run, "Ответ расширения", "READY", "access.profile.apply") + tree = client.get(f"/projects/{project_id}/metadata/tree") assert tree.status_code == 200 root = tree.json()["root"]