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"]