Use saved SMB credentials for AI structure
CI / python (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-05-21 23:43:40 +03:00
parent 9ea2ff5518
commit 65a1437c7c
7 changed files with 300 additions and 15 deletions
@@ -23,6 +23,8 @@ def prepare_ai_structure(
input_path: Path, input_path: Path,
output_path: Path, output_path: Path,
structure_only: bool = False, structure_only: bool = False,
display_input_path: str | None = None,
display_output_path: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if not input_path.exists(): if not input_path.exists():
raise FileNotFoundError(f"Input path not found: {input_path}") 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.") diagnostics.append("No 1C metadata/XML/BSL files or .cf/.cfe binaries were found.")
codex_root = output_path / _codex_folder_name(project_id) 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 / "manifest.json", manifest)
_write_json(output_path / "source_inventory.json", {"files": files}) _write_json(output_path / "source_inventory.json", {"files": files})
if snapshot is not None: if snapshot is not None:
@@ -78,9 +90,9 @@ def _inventory(root: Path) -> list[dict[str, Any]]:
def _manifest( def _manifest(
project_id: str, project_id: str,
input_path: Path, input_path: str,
output_path: Path, output_path: str,
codex_root: Path, codex_root: str,
files: list[dict[str, Any]], files: list[dict[str, Any]],
snapshot: SirSnapshot | None, snapshot: SirSnapshot | None,
normalized: NormalizedProject | None, normalized: NormalizedProject | None,
@@ -90,10 +102,10 @@ def _manifest(
return { return {
"version": AI_STRUCTURE_VERSION, "version": AI_STRUCTURE_VERSION,
"project_id": project_id, "project_id": project_id,
"input_path": str(input_path), "input_path": input_path,
"output_path": str(output_path), "output_path": output_path,
"codex_package_path": str(codex_root), "codex_package_path": codex_root,
"codex_package_folder": codex_root.name, "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", "status": "ready" if snapshot is not None or normalized is not None else "export_required",
"files_count": len(files), "files_count": len(files),
"binary_1c_files": binaries, "binary_1c_files": binaries,
@@ -135,6 +147,14 @@ def _codex_folder_name(project_id: str) -> str:
return f"codex-1c-context-{safe}" 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( def _write_codex_package(
root: Path, root: Path,
input_path: Path, input_path: Path,
@@ -7,7 +7,13 @@ from urllib.parse import quote
from api_server.html5 import _page, _project_link, _topbar 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) project_nav = "\n".join(_project_link(project, project_id) for project in projects)
return _page( return _page(
f"SFERA AI Structure - {project_id}", f"SFERA AI Structure - {project_id}",
@@ -24,7 +30,7 @@ def render_html5_ai_structure_page(*, project_id: str, projects: Iterable[object
</aside> </aside>
<section class="panel setup-main"> <section class="panel setup-main">
<div class="panel-title">Подготовка структуры</div> <div class="panel-title">Подготовка структуры</div>
{render_html5_ai_structure_form(project_id)} {render_html5_ai_structure_form(project_id, saved_credentials=saved_credentials)}
<p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p> <p class="object-summary">Пути должны быть доступны серверу SFERA/API. Для docker-test используйте папку, смонтированную или доступную внутри контейнера; локальные диски Windows и закрытые SMB-папки без учетных данных сервер не увидит.</p>
<div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div> <div data-html5-ai-structure-result>{render_html5_ai_structure_result(result)}</div>
</section> </section>
@@ -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""" return f"""
<form <form
class="ai-structure-form" class="ai-structure-form"
@@ -54,6 +64,22 @@ def render_html5_ai_structure_form(project_id: str) -> str:
<span>Project id</span> <span>Project id</span>
<input name="project_id" value="{escape(project_id)}" /> <input name="project_id" value="{escape(project_id)}" />
</label> </label>
<label>
<span>Домен</span>
<input name="smb_domain" value="{escape(saved_domain)}" autocomplete="username" />
</label>
<label>
<span>Логин SMB</span>
<input name="smb_username" value="{escape(saved_username)}" autocomplete="username" />
</label>
<label>
<span>Пароль SMB</span>
<input name="smb_password" type="password" placeholder="{escape(password_hint)}" autocomplete="current-password" />
</label>
<label class="checkbox-row">
<input name="save_smb_credentials" type="checkbox" value="1" checked />
<span>Сохранить</span>
</label>
<button class="primary" type="submit">Подготовить для ИИ</button> <button class="primary" type="submit">Подготовить для ИИ</button>
</form> </form>
""" """
@@ -3,6 +3,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
@@ -12,14 +13,23 @@ from api_server.html5_ai_structure import (
render_html5_ai_structure_result, render_html5_ai_structure_result,
) )
from api_server.html5_forms import form_value 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( def html5_ai_structure_page(
*, *,
project_id: str, project_id: str,
project_summaries: Callable[[], Iterable[object]], project_summaries: Callable[[], Iterable[object]],
load_credentials: Callable[[str], SmbCredentials | None] | None = None,
) -> str: ) -> 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( def html5_ai_structure_run(
@@ -27,6 +37,9 @@ def html5_ai_structure_run(
project_id: str, project_id: str,
form: dict[str, list[str]], form: dict[str, list[str]],
prepare: Callable[..., dict[str, Any]], 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: ) -> str:
effective_project_id = form_value(form, "project_id") or project_id effective_project_id = form_value(form, "project_id") or project_id
input_path = form_value(form, "input_path") 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С.") return render_html5_ai_structure_error("Заполните входную папку с .cf/.cfe или выгрузкой 1С.")
if not output_path: if not output_path:
return render_html5_ai_structure_error("Заполните папку результата.") 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: 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: except FileNotFoundError as error:
return render_html5_ai_structure_error(str(error)) return render_html5_ai_structure_error(str(error))
except PermissionError as error: except PermissionError as error:
return render_html5_ai_structure_error(f"Нет доступа к папке: {error}") return render_html5_ai_structure_error(f"Нет доступа к папке: {error}")
except OSError as error: except OSError as error:
return render_html5_ai_structure_error(f"Ошибка файловой системы: {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) return render_html5_ai_structure_result(result)
+42 -1
View File
@@ -284,6 +284,40 @@ _neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
_neo4j_password = os.environ.get("NEO4J_PASSWORD", "password") _neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
_EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None) _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 = { _ACCESS_TARGET_KINDS = {
NodeKind.CATALOG, NodeKind.CATALOG,
NodeKind.DOCUMENT, 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") @app.get("/html5/projects/{project_id}/ai-structure")
async def html5_project_ai_structure(project_id: str) -> Response: async def html5_project_ai_structure(project_id: str) -> Response:
return _html5_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, project_id=project_id,
form=form, form=form,
prepare=_prepare_ai_structure, 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,
) )
) )
@@ -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}"
@@ -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-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} .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){.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}}
+12
View File
@@ -1733,6 +1733,8 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path):
"Структура для ИИ", "Структура для ИИ",
"192.168.220.200", "192.168.220.200",
"Пути должны быть доступны серверу", "Пути должны быть доступны серверу",
"smb_username",
"smb_password",
full_page=True, 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") 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): def test_ai_structure_prepare_reports_cf_cfe_export_required(tmp_path: Path):
source = tmp_path / "cf-source" source = tmp_path / "cf-source"