From 65a1437c7c33554a819b9d5b845ae0e41ecbcf56 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Thu, 21 May 2026 23:43:40 +0300 Subject: [PATCH] Use saved SMB credentials for AI structure --- .../src/api_server/ai_structure_service.py | 36 +++-- .../src/api_server/html5_ai_structure.py | 32 ++++- .../html5_ai_structure_controller.py | 59 +++++++- services/api-server/src/api_server/main.py | 43 +++++- .../api-server/src/api_server/smb_paths.py | 131 ++++++++++++++++++ .../src/api_server/static/html5/html5.css | 2 +- services/api-server/tests/test_api.py | 12 ++ 7 files changed, 300 insertions(+), 15 deletions(-) create mode 100644 services/api-server/src/api_server/smb_paths.py diff --git a/services/api-server/src/api_server/ai_structure_service.py b/services/api-server/src/api_server/ai_structure_service.py index d9213ab..f50d166 100644 --- a/services/api-server/src/api_server/ai_structure_service.py +++ b/services/api-server/src/api_server/ai_structure_service.py @@ -23,6 +23,8 @@ def prepare_ai_structure( input_path: Path, output_path: Path, structure_only: bool = False, + display_input_path: str | None = None, + display_output_path: str | None = None, ) -> dict[str, Any]: if not input_path.exists(): raise FileNotFoundError(f"Input path not found: {input_path}") @@ -48,7 +50,17 @@ def prepare_ai_structure( diagnostics.append("No 1C metadata/XML/BSL files or .cf/.cfe binaries were found.") codex_root = output_path / _codex_folder_name(project_id) - manifest = _manifest(project_id, input_path, output_path, codex_root, files, snapshot, normalized, diagnostics, binaries) + manifest = _manifest( + project_id, + display_input_path or str(input_path), + display_output_path or str(output_path), + _join_display_path(display_output_path, codex_root.name) if display_output_path else str(codex_root), + files, + snapshot, + normalized, + diagnostics, + binaries, + ) _write_json(output_path / "manifest.json", manifest) _write_json(output_path / "source_inventory.json", {"files": files}) if snapshot is not None: @@ -78,9 +90,9 @@ def _inventory(root: Path) -> list[dict[str, Any]]: def _manifest( project_id: str, - input_path: Path, - output_path: Path, - codex_root: Path, + input_path: str, + output_path: str, + codex_root: str, files: list[dict[str, Any]], snapshot: SirSnapshot | None, normalized: NormalizedProject | None, @@ -90,10 +102,10 @@ def _manifest( return { "version": AI_STRUCTURE_VERSION, "project_id": project_id, - "input_path": str(input_path), - "output_path": str(output_path), - "codex_package_path": str(codex_root), - "codex_package_folder": codex_root.name, + "input_path": input_path, + "output_path": output_path, + "codex_package_path": codex_root, + "codex_package_folder": Path(codex_root).name if not codex_root.startswith("\\\\") else codex_root.rstrip("\\").rsplit("\\", 1)[-1], "status": "ready" if snapshot is not None or normalized is not None else "export_required", "files_count": len(files), "binary_1c_files": binaries, @@ -135,6 +147,14 @@ def _codex_folder_name(project_id: str) -> str: return f"codex-1c-context-{safe}" +def _join_display_path(root: str | None, child: str) -> str: + if not root: + return child + separator = "\\" if root.startswith("\\\\") or "\\" in root else "/" + cleaned = root.rstrip("/\\") + return f"{cleaned}{separator}{child}" + + def _write_codex_package( root: Path, input_path: Path, diff --git a/services/api-server/src/api_server/html5_ai_structure.py b/services/api-server/src/api_server/html5_ai_structure.py index 58c2761..947a45d 100644 --- a/services/api-server/src/api_server/html5_ai_structure.py +++ b/services/api-server/src/api_server/html5_ai_structure.py @@ -7,7 +7,13 @@ from urllib.parse import quote from api_server.html5 import _page, _project_link, _topbar -def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object], result: dict | None = None) -> str: +def render_html5_ai_structure_page( + *, + project_id: str, + projects: Iterable[object], + result: dict | None = None, + saved_credentials: dict[str, str] | None = None, +) -> str: project_nav = "\n".join(_project_link(project, project_id) for project in projects) return _page( f"SFERA AI Structure - {project_id}", @@ -24,7 +30,7 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
Подготовка структуры
- {render_html5_ai_structure_form(project_id)} + {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}

Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.

{render_html5_ai_structure_result(result)}
@@ -34,7 +40,11 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object ) -def render_html5_ai_structure_form(project_id: str) -> str: +def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[str, str] | None = None) -> str: + saved_credentials = saved_credentials or {} + saved_username = str(saved_credentials.get("username") or "") + saved_domain = str(saved_credentials.get("domain") or "") + password_hint = "Пароль сохранен, оставьте пустым чтобы использовать его" if saved_credentials.get("password") else "Пароль SMB" return f"""
str: Project id + + + +
""" diff --git a/services/api-server/src/api_server/html5_ai_structure_controller.py b/services/api-server/src/api_server/html5_ai_structure_controller.py index e581167..817cbdc 100644 --- a/services/api-server/src/api_server/html5_ai_structure_controller.py +++ b/services/api-server/src/api_server/html5_ai_structure_controller.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterable from pathlib import Path from typing import Any +from uuid import uuid4 from fastapi import HTTPException @@ -12,14 +13,23 @@ from api_server.html5_ai_structure import ( render_html5_ai_structure_result, ) from api_server.html5_forms import form_value +from api_server.smb_paths import copy_local_tree_to_smb, copy_smb_tree_to_local, is_unc_path, remove_tree + + +SmbCredentials = dict[str, str] def html5_ai_structure_page( *, project_id: str, project_summaries: Callable[[], Iterable[object]], + load_credentials: Callable[[str], SmbCredentials | None] | None = None, ) -> str: - return render_html5_ai_structure_page(project_id=project_id, projects=project_summaries()) + return render_html5_ai_structure_page( + project_id=project_id, + projects=project_summaries(), + saved_credentials=load_credentials(project_id) if load_credentials else None, + ) def html5_ai_structure_run( @@ -27,6 +37,9 @@ def html5_ai_structure_run( project_id: str, form: dict[str, list[str]], prepare: Callable[..., dict[str, Any]], + work_root: Path, + load_credentials: Callable[[str], SmbCredentials | None] | None = None, + save_credentials: Callable[[str, SmbCredentials], None] | None = None, ) -> str: effective_project_id = form_value(form, "project_id") or project_id input_path = form_value(form, "input_path") @@ -35,12 +48,54 @@ def html5_ai_structure_run( return render_html5_ai_structure_error("Заполните входную папку с .cf/.cfe или выгрузкой 1С.") if not output_path: return render_html5_ai_structure_error("Заполните папку результата.") + saved = load_credentials(project_id) if load_credentials else None + username = form_value(form, "smb_username") or (saved or {}).get("username", "") + password = form_value(form, "smb_password") or (saved or {}).get("password", "") + domain = form_value(form, "smb_domain") or (saved or {}).get("domain", "") + should_save = bool(form_value(form, "save_smb_credentials")) + uses_smb = is_unc_path(input_path) or is_unc_path(output_path) + if uses_smb and (not username or not password): + return render_html5_ai_structure_error("Для сетевого UNC-пути укажите логин и пароль SMB.") + if should_save and save_credentials and username and password: + save_credentials(project_id, {"username": username, "password": password, "domain": domain}) + + work_dir = work_root / f"{effective_project_id}-{uuid4().hex}" try: - result = prepare(project_id=effective_project_id, input_path=Path(input_path), output_path=Path(output_path)) + work_dir.mkdir(parents=True, exist_ok=True) + local_input = work_dir / "input" if is_unc_path(input_path) else Path(input_path) + local_output = work_dir / "output" if is_unc_path(output_path) else Path(output_path) + if is_unc_path(input_path): + copy_smb_tree_to_local( + source=input_path, + target=local_input, + username=username, + password=password, + domain=domain or None, + ) + result = prepare( + project_id=effective_project_id, + input_path=local_input, + output_path=local_output, + display_input_path=input_path, + display_output_path=output_path, + ) + if is_unc_path(output_path): + copy_local_tree_to_smb( + source=local_output, + target=output_path, + username=username, + password=password, + domain=domain or None, + ) except FileNotFoundError as error: return render_html5_ai_structure_error(str(error)) except PermissionError as error: return render_html5_ai_structure_error(f"Нет доступа к папке: {error}") except OSError as error: return render_html5_ai_structure_error(f"Ошибка файловой системы: {error}") + except RuntimeError as error: + return render_html5_ai_structure_error(str(error)) + finally: + if work_dir.exists(): + remove_tree(work_dir, expected_parent=work_root) return render_html5_ai_structure_result(result) diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 795576c..1e6477a 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -284,6 +284,40 @@ _neo4j_user = os.environ.get("NEO4J_USER", "neo4j") _neo4j_password = os.environ.get("NEO4J_PASSWORD", "password") _EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None) + +def _load_ai_structure_smb_credentials(project_id: str) -> dict[str, str] | None: + try: + payload = _storage.read_document("ai_structure_smb_credentials", project_id) + except FileNotFoundError: + return None + password_value = str(payload.get("password_b64") or "") + try: + password = base64.b64decode(password_value.encode("ascii")).decode("utf-8") if password_value else "" + except (ValueError, UnicodeDecodeError): + password = "" + username = str(payload.get("username") or "") + if not username and not password: + return None + return { + "username": username, + "domain": str(payload.get("domain") or ""), + "password": password, + } + + +def _save_ai_structure_smb_credentials(project_id: str, credentials: dict[str, str]) -> None: + password = str(credentials.get("password") or "") + _storage.write_document( + "ai_structure_smb_credentials", + project_id, + { + "project_id": project_id, + "username": str(credentials.get("username") or ""), + "domain": str(credentials.get("domain") or ""), + "password_b64": base64.b64encode(password.encode("utf-8")).decode("ascii"), + }, + ) + _ACCESS_TARGET_KINDS = { NodeKind.CATALOG, NodeKind.DOCUMENT, @@ -1585,7 +1619,11 @@ async def html5_project_access(project_id: str, profile: str | None = None) -> R @app.get("/html5/projects/{project_id}/ai-structure") async def html5_project_ai_structure(project_id: str) -> Response: return _html5_response( - _html5_ai_structure_page(project_id=project_id, project_summaries=_project_summaries) + _html5_ai_structure_page( + project_id=project_id, + project_summaries=_project_summaries, + load_credentials=_load_ai_structure_smb_credentials, + ) ) @@ -1597,6 +1635,9 @@ async def html5_project_ai_structure_run(project_id: str, request: Request) -> R project_id=project_id, form=form, prepare=_prepare_ai_structure, + work_root=_storage.root / "ai_structure_work", + load_credentials=_load_ai_structure_smb_credentials, + save_credentials=_save_ai_structure_smb_credentials, ) ) diff --git a/services/api-server/src/api_server/smb_paths.py b/services/api-server/src/api_server/smb_paths.py new file mode 100644 index 0000000..146c09b --- /dev/null +++ b/services/api-server/src/api_server/smb_paths.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import shutil +from pathlib import Path +from typing import Any + + +def is_unc_path(path: str) -> bool: + return path.startswith("\\\\") + + +def copy_smb_tree_to_local( + *, + source: str, + target: Path, + username: str, + password: str, + domain: str | None = None, +) -> None: + smbclient = _smbclient() + server, _share, _relative = parse_unc_path(source) + _register_session(smbclient, server=server, username=username, password=password, domain=domain) + _copy_smb_directory(smbclient, source.rstrip("\\"), target) + + +def copy_local_tree_to_smb( + *, + source: Path, + target: str, + username: str, + password: str, + domain: str | None = None, +) -> None: + smbclient = _smbclient() + server, _share, _relative = parse_unc_path(target) + _register_session(smbclient, server=server, username=username, password=password, domain=domain) + remote_root = target.rstrip("\\") + _ensure_smb_directory(smbclient, remote_root) + if source.is_file(): + _copy_local_file_to_smb(smbclient, source, f"{remote_root}\\{source.name}") + return + for path in sorted(source.rglob("*")): + relative = path.relative_to(source) + remote_relative = str(relative).replace("/", "\\") + remote = f"{remote_root}\\{remote_relative}" + if path.is_dir(): + _ensure_smb_directory(smbclient, remote) + elif path.is_file(): + _ensure_smb_directory(smbclient, remote.rsplit("\\", 1)[0]) + _copy_local_file_to_smb(smbclient, path, remote) + + +def parse_unc_path(path: str) -> tuple[str, str, str]: + parts = [part for part in path.strip("\\").split("\\") if part] + if len(parts) < 2: + raise ValueError("UNC путь должен содержать сервер и share: \\\\server\\share.") + server, share = parts[0], parts[1] + relative = "\\".join(parts[2:]) + return server, share, relative + + +def remove_tree(path: Path, *, expected_parent: Path) -> None: + resolved = path.resolve() + parent = expected_parent.resolve() + if resolved == parent or parent not in resolved.parents: + raise ValueError(f"Refusing to remove path outside work root: {resolved}") + if resolved.exists(): + shutil.rmtree(resolved) + + +def _smbclient() -> Any: + try: + import smbclient + except ImportError as error: + raise RuntimeError("SMB client dependency is not installed on the API server.") from error + return smbclient + + +def _register_session(smbclient: Any, *, server: str, username: str, password: str, domain: str | None) -> None: + qualified_user = f"{domain}\\{username}" if domain else username + smbclient.register_session(server, username=qualified_user, password=password) + + +def _copy_smb_directory(smbclient: Any, source: str, target: Path) -> None: + target.mkdir(parents=True, exist_ok=True) + for item in smbclient.scandir(source): + destination = target / item.name + child_source = f"{source}\\{item.name}" + try: + if item.is_dir(): + _copy_smb_directory(smbclient, child_source, destination) + continue + except OSError: + continue + with smbclient.open_file(child_source, mode="rb") as remote_file: + with destination.open("wb") as local_file: + shutil.copyfileobj(remote_file, local_file, length=1024 * 1024) + + +def _copy_local_file_to_smb(smbclient: Any, source: Path, target: str) -> None: + with source.open("rb") as local_file: + with smbclient.open_file(target, mode="wb") as remote_file: + shutil.copyfileobj(local_file, remote_file, length=1024 * 1024) + + +def _ensure_smb_directory(smbclient: Any, path: str) -> None: + normalized = path.rstrip("\\") + try: + if smbclient.path.isdir(normalized): + return + except OSError: + pass + parent = _unc_parent_path(normalized) + if parent and parent != normalized: + _ensure_smb_directory(smbclient, parent) + try: + smbclient.mkdir(normalized) + except OSError: + if not smbclient.path.isdir(normalized): + raise + + +def _unc_parent_path(path: str) -> str | None: + server, share, relative = parse_unc_path(path) + if not relative: + return None + parts = [part for part in relative.split("\\") if part] + if len(parts) <= 1: + return f"\\\\{server}\\{share}" + parent_relative = "\\".join(parts[:-1]) + return f"\\\\{server}\\{share}\\{parent_relative}" 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 85b112c..0e42953 100644 --- a/services/api-server/src/api_server/static/html5/html5.css +++ b/services/api-server/src/api_server/static/html5/html5.css @@ -13,7 +13,7 @@ .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-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff} -.ai-structure-form{display:grid;grid-template-columns:1fr 1fr 220px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;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}.ai-structure-result{background:#fff} +.ai-structure-form{display:grid;grid-template-columns:1.4fr 1.4fr 180px 120px 180px 180px 120px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;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}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px}.ai-structure-result{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}} diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 6b87b2b..6693415 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1733,6 +1733,8 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): "Структура для ИИ", "192.168.220.200", "Пути должны быть доступны серверу", + "smb_username", + "smb_password", full_page=True, ) @@ -1748,6 +1750,16 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): ) assert_html5_response_contract(html5_missing, "ошибка", "Input path not found") + html5_smb_without_credentials = client.post( + "/html5/projects/ai-demo/ai-structure/run", + data={ + "project_id": "ai-demo-html5", + "input_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CF", + "output_path": r"\\192.168.220.200\mst\1c\MARKA\CODEX\CODEX", + }, + ) + assert_html5_response_contract(html5_smb_without_credentials, "ошибка", "логин и пароль SMB") + def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path): source = tmp_path / "cf-source"