Show effective access roles in HTML5 workspace
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-21 19:40:12 +03:00
parent 87236606d1
commit 5f066d2f6b
5 changed files with 76 additions and 3 deletions
@@ -70,7 +70,8 @@ def render_html5_access_page(
<div class="panel-title">Группы доступа</div> <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="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="panel-title">Пользователи</div>
<div class="access-list">{''.join(_user_card(item) for item in users[:80]) or '<p class="muted padded">Пользователи не найдены</p>'}</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> </aside>
</section> </section>
</main> </main>
@@ -157,6 +158,35 @@ def render_html5_access_profile_apply_result(*, project_id: str, response: objec
""" """
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: def render_html5_access_profile(*, project_id: str, profile: object | None) -> str:
if profile is None: if profile is None:
return """ return """
@@ -308,11 +338,21 @@ def _group_card(group: object) -> str:
return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(profile)} · {len(users)} пользователей</small></article>' return f'<article class="access-card"><strong>{escape(name)}</strong><small>{escape(profile)} · {len(users)} пользователей</small></article>'
def _user_card(user: object) -> str: def _user_card(project_id: str, user: object) -> str:
name = str(getattr(user, "name", "")) name = str(getattr(user, "name", ""))
full_name = str(getattr(user, "full_name", "") or "") full_name = str(getattr(user, "full_name", "") or "")
groups = list(getattr(user, "groups", []) 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>' 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: def _operation_card(operation: dict) -> str:
@@ -359,3 +399,10 @@ class _DictProfile:
self.roles = payload.get("roles") or [] self.roles = payload.get("roles") or []
self.attributes = payload.get("attributes") or {} self.attributes = payload.get("attributes") or {}
self.source = payload.get("source") or "workspace" 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 "")
@@ -11,6 +11,7 @@ from api_server.html5_access import (
render_html5_access_profile_preview, render_html5_access_profile_preview,
render_html5_access_publish_plan, render_html5_access_publish_plan,
render_html5_access_publish_result, render_html5_access_publish_result,
render_html5_access_user_detail,
) )
from api_server.html5_forms import form_value, html5_csv_values from api_server.html5_forms import form_value, html5_csv_values
@@ -106,6 +107,15 @@ async def html5_access_profile_apply(
return render_html5_access_profile_apply_result(project_id=project_id, response=response, plan=plan) return render_html5_access_profile_apply_result(project_id=project_id, response=response, plan=plan)
async def html5_access_user_detail(
*,
project_id: str,
user_name: str,
access_user: Callable[[str, str], Awaitable[dict]],
) -> str:
return render_html5_access_user_detail(project_id=project_id, user_payload=await access_user(project_id, user_name))
def _selected_profile( def _selected_profile(
normalized: object, normalized: object,
profile_name: str | None, profile_name: str | None,
@@ -61,6 +61,7 @@ from api_server.html5_access_controller import (
html5_access_profile_preview as _html5_access_profile_preview, html5_access_profile_preview as _html5_access_profile_preview,
html5_access_publish_dry_run as _html5_access_publish_dry_run, html5_access_publish_dry_run as _html5_access_publish_dry_run,
html5_access_publish_plan as _html5_access_publish_plan, html5_access_publish_plan as _html5_access_publish_plan,
html5_access_user_detail as _html5_access_user_detail,
) )
from api_server.html5_editor_controller import ( from api_server.html5_editor_controller import (
html5_editor_page as _html5_editor_page, html5_editor_page as _html5_editor_page,
@@ -1606,6 +1607,17 @@ async def html5_project_access_profile_publish_dry_run(project_id: str, profile_
) )
@app.get("/html5/projects/{project_id}/access/users/{user_name}")
async def html5_project_access_user(project_id: str, user_name: str) -> Response:
return _html5_response(
await _html5_access_user_detail(
project_id=project_id,
user_name=user_name,
access_user=get_project_access_user,
)
)
@app.post("/html5/projects/{project_id}/forms/editor/preview") @app.post("/html5/projects/{project_id}/forms/editor/preview")
async def html5_project_form_editor_preview(project_id: str, request: Request) -> Response: async def html5_project_form_editor_preview(project_id: str, request: Request) -> Response:
form = await _html5_form_data(request) form = await _html5_form_data(request)
@@ -12,6 +12,7 @@
.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} .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} .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}
.access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff} .access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff}
.access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#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){.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){.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){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}} @media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
+3
View File
@@ -1643,6 +1643,9 @@ def test_import_supports_structure_only_indexing(monkeypatch, tmp_path: Path):
) )
assert_html5_response_contract(html5_apply, "сохранено", "HTML5ПрофильHTTP", "CREATE_ACCESS_PROFILE") assert_html5_response_contract(html5_apply, "сохранено", "HTML5ПрофильHTTP", "CREATE_ACCESS_PROFILE")
html5_user = client.get(f"/html5/projects/{project_id}/access/users/ivanov")
assert_html5_response_contract(html5_user, "пользователь", "ivanov", "Эффективные роли", "Роль.Менеджер")
access_plan = client.get( access_plan = client.get(
f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan" f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan"
) )