Create access profiles from HTML5 workspace
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from html import escape
|
||||
import json
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -63,6 +64,7 @@ def render_html5_access_page(
|
||||
{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>
|
||||
@@ -76,6 +78,85 @@ def render_html5_access_page(
|
||||
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_profile(*, project_id: str, profile: object | None) -> str:
|
||||
if profile is None:
|
||||
return """
|
||||
@@ -248,6 +329,15 @@ def _check_card(check: object) -> str:
|
||||
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)
|
||||
@@ -259,5 +349,13 @@ def _assignment_count(profiles: list[object], groups: list[object], users: list[
|
||||
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)
|
||||
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"
|
||||
|
||||
@@ -7,9 +7,12 @@ from fastapi import HTTPException
|
||||
|
||||
from api_server.html5_access import (
|
||||
render_html5_access_page,
|
||||
render_html5_access_profile_apply_result,
|
||||
render_html5_access_profile_preview,
|
||||
render_html5_access_publish_plan,
|
||||
render_html5_access_publish_result,
|
||||
)
|
||||
from api_server.html5_forms import form_value, html5_csv_values
|
||||
|
||||
|
||||
def html5_access_page(
|
||||
@@ -70,6 +73,39 @@ async def html5_access_publish_dry_run(
|
||||
return render_html5_access_publish_result(project_id=project_id, result=result)
|
||||
|
||||
|
||||
async def html5_access_profile_preview(
|
||||
*,
|
||||
project_id: str,
|
||||
form: dict[str, list[str]],
|
||||
preview_profile: Callable[[str, object], Awaitable[Any]],
|
||||
draft_request: Callable[..., object],
|
||||
) -> str:
|
||||
draft = await preview_profile(project_id, _draft_request_from_form(form, draft_request))
|
||||
return render_html5_access_profile_preview(draft=draft)
|
||||
|
||||
|
||||
async def html5_access_profile_apply(
|
||||
*,
|
||||
project_id: str,
|
||||
form: dict[str, list[str]],
|
||||
apply_profile: Callable[[str, object], Awaitable[Any]],
|
||||
apply_request: Callable[..., object],
|
||||
normalized_project: Callable[[str], object],
|
||||
access_profile_by_name: Callable[[object, str], object | None],
|
||||
access_publish_plan: Callable[[object, object], object],
|
||||
) -> str:
|
||||
request = _draft_request_from_form(form, apply_request, author="html5")
|
||||
response = await apply_profile(project_id, request)
|
||||
profile_name = str(response.profile.get("name") or response.profile.get("qualified_name") or "")
|
||||
plan = None
|
||||
if profile_name:
|
||||
normalized = normalized_project(project_id)
|
||||
profile = access_profile_by_name(normalized, profile_name)
|
||||
if profile is not None:
|
||||
plan = access_publish_plan(normalized, profile)
|
||||
return render_html5_access_profile_apply_result(project_id=project_id, response=response, plan=plan)
|
||||
|
||||
|
||||
def _selected_profile(
|
||||
normalized: object,
|
||||
profile_name: str | None,
|
||||
@@ -82,3 +118,17 @@ def _selected_profile(
|
||||
if not profile_name:
|
||||
return profiles[0]
|
||||
return access_profile_by_name(normalized, profile_name) or profiles[0]
|
||||
|
||||
|
||||
def _draft_request_from_form(form: dict[str, list[str]], request_factory: Callable[..., object], **extra: object) -> object:
|
||||
name = form_value(form, "name")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="Access profile name is required")
|
||||
payload = {
|
||||
"name": name,
|
||||
"target_objects": html5_csv_values(form_value(form, "target_objects") or ""),
|
||||
"permissions": html5_csv_values(form_value(form, "permissions") or "read"),
|
||||
"source_user": form_value(form, "source_user"),
|
||||
**extra,
|
||||
}
|
||||
return request_factory(**payload)
|
||||
|
||||
@@ -57,6 +57,8 @@ from api_server.html5_forms import (
|
||||
)
|
||||
from api_server.html5_access_controller import (
|
||||
html5_access_page as _html5_access_page,
|
||||
html5_access_profile_apply as _html5_access_profile_apply,
|
||||
html5_access_profile_preview as _html5_access_profile_preview,
|
||||
html5_access_publish_dry_run as _html5_access_publish_dry_run,
|
||||
html5_access_publish_plan as _html5_access_publish_plan,
|
||||
)
|
||||
@@ -1564,6 +1566,35 @@ async def html5_project_access_profile_plan(project_id: str, profile_name: str)
|
||||
)
|
||||
|
||||
|
||||
@app.post("/html5/projects/{project_id}/access/profile-preview")
|
||||
async def html5_project_access_profile_preview(project_id: str, request: Request) -> Response:
|
||||
form = await _html5_form_data(request)
|
||||
return _html5_response(
|
||||
await _html5_access_profile_preview(
|
||||
project_id=project_id,
|
||||
form=form,
|
||||
preview_profile=preview_project_access_profile,
|
||||
draft_request=AccessProfileDraftRequest,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.post("/html5/projects/{project_id}/access/profiles")
|
||||
async def html5_project_access_profile_apply(project_id: str, request: Request) -> Response:
|
||||
form = await _html5_form_data(request)
|
||||
return _html5_response(
|
||||
await _html5_access_profile_apply(
|
||||
project_id=project_id,
|
||||
form=form,
|
||||
apply_profile=apply_project_access_profile,
|
||||
apply_request=AccessProfileApplyRequest,
|
||||
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(
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
.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}
|
||||
.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}
|
||||
@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-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}}
|
||||
@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}}
|
||||
|
||||
@@ -1617,9 +1617,32 @@ def test_import_supports_structure_only_indexing(monkeypatch, tmp_path: Path):
|
||||
"НовыйПрофильHTTP",
|
||||
"План публикации",
|
||||
"Dry-run в 1С",
|
||||
"Новый профиль доступа",
|
||||
full_page=True,
|
||||
)
|
||||
|
||||
html5_preview = client.post(
|
||||
f"/html5/projects/{project_id}/access/profile-preview",
|
||||
data={
|
||||
"name": "HTML5ПрофильHTTP",
|
||||
"target_objects": "HTTPСервис.ПубличныйAPI",
|
||||
"permissions": "read",
|
||||
"source_user": "ivanov",
|
||||
},
|
||||
)
|
||||
assert_html5_response_contract(html5_preview, "предпросмотр", "Роль.Менеджер", "ПрофильГруппыДоступа.HTML5ПрофильHTTP")
|
||||
|
||||
html5_apply = client.post(
|
||||
f"/html5/projects/{project_id}/access/profiles",
|
||||
data={
|
||||
"name": "HTML5ПрофильHTTP",
|
||||
"target_objects": "HTTPСервис.ПубличныйAPI",
|
||||
"permissions": "read",
|
||||
"source_user": "ivanov",
|
||||
},
|
||||
)
|
||||
assert_html5_response_contract(html5_apply, "сохранено", "HTML5ПрофильHTTP", "CREATE_ACCESS_PROFILE")
|
||||
|
||||
access_plan = client.get(
|
||||
f"/html5/projects/{project_id}/access/profiles/{quote('НовыйПрофильHTTP')}/plan"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user