From 41dc88c33b166b917ca148f6ad57101ec7d909ce Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sat, 16 May 2026 23:55:02 +0300 Subject: [PATCH] Add HTML5 metadata authoring form --- services/api-server/src/api_server/html5.py | 138 ++++++++++++++++++++ services/api-server/src/api_server/main.py | 95 ++++++++++++++ services/api-server/tests/test_api.py | 50 +++++++ 3 files changed, 283 insertions(+) diff --git a/services/api-server/src/api_server/html5.py b/services/api-server/src/api_server/html5.py index e4a19c9..b0aa39f 100644 --- a/services/api-server/src/api_server/html5.py +++ b/services/api-server/src/api_server/html5.py @@ -233,6 +233,7 @@ def render_html5_editor( {render_html5_project_report(project_id, None)} {render_html5_review(project_id, None)} {render_html5_authoring_preview(project_id, None)} + {render_html5_metadata_authoring(project_id)} {render_html5_authoring_changes(project_id, None)} @@ -508,6 +509,113 @@ def render_html5_authoring_apply_result(project_id: str, result: object | None = """ +def render_html5_metadata_authoring(project_id: str) -> str: + return f""" +
+
Metadata draft
+
+ + + + + + + + + + + +
+ {render_html5_metadata_preview_result(project_id)} +
+ """ + + +def render_html5_metadata_preview_result( + project_id: str, + preview: object | None = None, + error: str | None = None, + request_payload: dict | None = None, +) -> str: + if preview is None and error is None: + return '
' + if error: + return f""" +
+
Metadata preview
+

{escape(error)}

+
+ """ + changed = bool(getattr(preview, "changed", False)) + added = getattr(preview, "added_lines", 0) + target = getattr(preview, "target", None) + target_name = getattr(target, "qualified_name", None) or getattr(target, "name", None) or "target unavailable" + checks = getattr(preview, "checks", []) or [] + diff = getattr(preview, "semantic_diff", []) or [] + version_preview = getattr(preview, "version_preview", None) + next_version_id = str(getattr(version_preview, "next_version_id", "")) + check_rows = "".join(_authoring_check_item(check) for check in checks[:8]) + diff_rows = "".join(_authoring_diff_item(line) for line in diff[:12]) or '

Diff пустой

' + apply_form = ( + _metadata_apply_form(project_id, request_payload or {}, next_version_id) + if changed and next_version_id + else "" + ) + return f""" +
+
Metadata preview · {'changed' if changed else 'unchanged'}
+
+ {escape(str(target_name))} + +{escape(str(added))} / -0 + {escape(next_version_id or "version preview unavailable")} +
+
{check_rows}
+
{diff_rows}
+ {apply_form} +
+ """ + + +def render_html5_metadata_apply_result(project_id: str, result: object | None = None, error: str | None = None) -> str: + if result is None and error is None: + return '
' + if error: + return f""" +
+
Metadata apply
+

{escape(error)}

+
+ """ + status = str(getattr(result, "status", "UNKNOWN")) + change_id = str(getattr(result, "change_id", "")) + version = getattr(result, "version", None) + version_id = str(getattr(version, "version_id", "")) + return f""" +
+
Metadata apply
+
+ {escape(status)} + {escape(change_id)} + {escape(version_id)} +
+

Metadata draft применен в workspace для проекта {escape(project_id)}.

+
+ """ + + def render_html5_authoring_change_detail(project_id: str, preview: object | None) -> str: if preview is None: return f""" @@ -1248,6 +1356,36 @@ def _authoring_apply_change_set_form(project_id: str, payload: dict, next_versio """ +def _metadata_apply_form(project_id: str, payload: dict, next_version_id: str) -> str: + return f""" +
+ + + + + + + + + + + + + + +
+ {render_html5_metadata_apply_result(project_id)} + """ + + def _node_source_text(node: object | None) -> str: if node is None: return "// Модуль не найден в snapshot.\n// HTML5 IDE показывает серверный fallback без клиентского JS." diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index cd4b0d9..62964ec 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -44,6 +44,8 @@ from api_server.html5 import ( render_html5_authoring_rollback_result, render_html5_editor, render_html5_index, + render_html5_metadata_apply_result, + render_html5_metadata_preview_result, render_html5_project_setup, render_html5_project_rows, render_html5_project_report, @@ -1851,6 +1853,39 @@ async def html5_project_authoring_apply_change_set(project_id: str, request: Req return Response(html, media_type="text/html; charset=utf-8") +@app.post("/html5/projects/{project_id}/authoring/metadata-object-preview") +async def html5_project_authoring_metadata_object_preview(project_id: str, request: Request) -> Response: + form = await _html5_form_data(request) + raw_payload = _html5_metadata_payload(form) + payload = AuthoringMetadataObjectPreviewRequest(**_html5_metadata_request_payload(raw_payload)) + try: + preview = _authoring_metadata_object_preview(project_id, payload) + html = render_html5_metadata_preview_result(project_id, preview, request_payload=raw_payload) + except (HTTPException, ValueError) as error: + detail = getattr(error, "detail", str(error)) + html = render_html5_metadata_preview_result(project_id, error=str(detail)) + return Response(html, media_type="text/html; charset=utf-8") + + +@app.post("/html5/projects/{project_id}/authoring/apply-metadata-object") +async def html5_project_authoring_apply_metadata_object(project_id: str, request: Request) -> Response: + form = await _html5_form_data(request) + raw_payload = _html5_metadata_payload(form) + payload = AuthoringApplyMetadataObjectRequest( + **_html5_metadata_request_payload(raw_payload), + expected_next_version_id=_form_value(form, "expected_next_version_id") or "", + approved_by=_form_value(form, "approved_by") or "", + approval_note=_form_value(form, "approval_note"), + ) + try: + result = await authoring_apply_metadata_object(project_id, payload) + html = render_html5_metadata_apply_result(project_id, result) + except (HTTPException, ValueError) as error: + detail = getattr(error, "detail", str(error)) + html = render_html5_metadata_apply_result(project_id, error=str(detail)) + return Response(html, media_type="text/html; charset=utf-8") + + @app.get("/html5/projects/{project_id}/setup") async def html5_project_setup(project_id: str) -> Response: setup = _project_setup_response(project_id) @@ -8004,6 +8039,66 @@ def _form_value(form: dict[str, list[str]], key: str) -> str | None: return value or None +def _html5_metadata_payload(form: dict[str, list[str]]) -> dict: + return { + "object_kind": _form_value(form, "object_kind") or "DOCUMENT", + "name": _form_value(form, "name") or "", + "synonym": _form_value(form, "synonym"), + "attributes": _html5_metadata_attributes(_form_value(form, "attributes") or ""), + "tabular_sections": _html5_metadata_tabular_sections(_form_value(form, "tabular_sections") or ""), + "forms": _html5_csv_values(_form_value(form, "forms") or ""), + "commands": _html5_metadata_commands(_form_value(form, "commands") or ""), + "task_id": _form_value(form, "task_id"), + "session_id": _form_value(form, "session_id"), + "user_id": _form_value(form, "user_id"), + "_raw_attributes": _form_value(form, "attributes") or "", + "_raw_tabular_sections": _form_value(form, "tabular_sections") or "", + "_raw_forms": _form_value(form, "forms") or "", + "_raw_commands": _form_value(form, "commands") or "", + } + + +def _html5_metadata_request_payload(payload: dict) -> dict: + return {key: value for key, value in payload.items() if not key.startswith("_raw_")} + + +def _html5_csv_values(raw: str) -> list[str]: + return [item.strip() for item in raw.replace("\n", ",").split(",") if item.strip()] + + +def _html5_metadata_attributes(raw: str) -> list[dict]: + attributes: list[dict] = [] + for item in _html5_csv_values(raw): + name, _, type_name = item.partition(":") + if name.strip(): + attributes.append({"name": name.strip(), "type": type_name.strip() or "Строка"}) + return attributes + + +def _html5_metadata_commands(raw: str) -> list[dict]: + commands: list[dict] = [] + for item in _html5_csv_values(raw): + name, _, handler = item.partition(":") + if name.strip(): + commands.append({"name": name.strip(), "handler": handler.strip() or None}) + return commands + + +def _html5_metadata_tabular_sections(raw: str) -> list[dict]: + sections: list[dict] = [] + for item in _html5_csv_values(raw): + name, _, attrs = item.partition("[") + if not name.strip(): + continue + attributes = [] + for attr in attrs.rstrip("]").split(";"): + attr_name, _, attr_type = attr.partition(":") + if attr_name.strip(): + attributes.append({"name": attr_name.strip(), "type": attr_type.strip() or "Строка"}) + sections.append({"name": name.strip(), "attributes": attributes}) + return sections + + def _current_import_source(project_id: str) -> ImportSourceKind: setup = _project_setup_response(project_id) if setup.current_source is not None: diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 9d2d60a..1601210 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -117,6 +117,9 @@ def test_html5_server_rendered_project_editor(tmp_path: Path): assert f'hx-post="/html5/projects/{project_id}/authoring/completion-preview"' in editor.text assert "data-html5-authoring-diff-form" in editor.text assert f'hx-post="/html5/projects/{project_id}/authoring/semantic-diff-preview"' in editor.text + assert "data-html5-metadata-authoring" in editor.text + assert "data-html5-metadata-preview-form" in editor.text + assert f'hx-post="/html5/projects/{project_id}/authoring/metadata-object-preview"' in editor.text assert "data-html5-authoring-changes" in editor.text assert f'hx-get="/html5/projects/{project_id}/authoring/changes"' in editor.text assert 'hx-get="/html5/projects/' in editor.text @@ -2723,6 +2726,53 @@ def test_authoring_metadata_object_preview_and_apply(tmp_path: Path): assert any("ТабличнаяЧасть.Товары" in row["text"] for row in preview_payload["semantic_diff"]) assert any("Команда.Заполнить" in row["text"] for row in preview_payload["semantic_diff"]) + html5_preview = client.post( + f"/html5/projects/{project_id}/authoring/metadata-object-preview", + data={ + "object_kind": "DOCUMENT", + "name": "ЗаявкаНаЗакупкуHtml5", + "synonym": "Заявка на закупку HTML5", + "attributes": "Контрагент:СправочникСсылка.Контрагенты", + "tabular_sections": "Товары[Номенклатура:СправочникСсылка.Номенклатура;Количество:Число]", + "forms": "ФормаДокумента", + "commands": "Заполнить:ЗаполнитьКоманда", + "task_id": "task.metadata", + "session_id": "session.metadata", + "user_id": "dev.ivan", + }, + ) + assert html5_preview.status_code == 200 + assert "text/html" in html5_preview.headers["content-type"] + assert "data-html5-metadata-preview-result" in html5_preview.text + assert "data-html5-metadata-apply-form" in html5_preview.text + assert f'hx-post="/html5/projects/{project_id}/authoring/apply-metadata-object"' in html5_preview.text + assert "Документ.ЗаявкаНаЗакупкуHtml5" in html5_preview.text + assert "Реквизит.Контрагент" in html5_preview.text + assert "