10117 lines
401 KiB
Python
10117 lines
401 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import base64
|
||
import hashlib
|
||
import os
|
||
import re
|
||
import shutil
|
||
import threading
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
import zipfile
|
||
import xml.etree.ElementTree as ET
|
||
from collections import Counter
|
||
from difflib import SequenceMatcher
|
||
from enum import Enum
|
||
from pathlib import Path
|
||
from typing import Any, Callable
|
||
from urllib.parse import parse_qs, quote, urljoin, urlsplit, urlunsplit
|
||
from uuid import uuid4
|
||
|
||
from collaboration import (
|
||
ActivityEvent,
|
||
ChangeSession,
|
||
Comment,
|
||
InMemoryCollaborationStore,
|
||
Ownership,
|
||
Task,
|
||
User,
|
||
)
|
||
from fastapi import FastAPI, HTTPException, Request
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
|
||
from neo4j import AsyncGraphDatabase
|
||
from pydantic import BaseModel, Field
|
||
|
||
from api_server.html5 import (
|
||
render_html5_editor,
|
||
render_html5_index,
|
||
render_html5_project_setup,
|
||
render_html5_project_rows,
|
||
render_html5_import_check,
|
||
render_html5_setup_summary,
|
||
render_html5_source,
|
||
render_html5_status,
|
||
render_html5_symbols,
|
||
)
|
||
from impact_engine import object_impact, routine_impact
|
||
from incremental_indexer import rebuild_changed_file
|
||
from integration_topology import IntegrationKind, build_integration_topology
|
||
from job_topology import snapshot_scheduled_jobs
|
||
from knowledge_base import InMemoryKnowledgeBase, KnowledgePack, KnowledgeRecord, KnowledgeScope
|
||
from operations_core import (
|
||
AiUsageRecord,
|
||
AiUsageSummary,
|
||
InMemoryOperationsStore,
|
||
LicenseState,
|
||
MarketplacePackage,
|
||
MetricSample,
|
||
OperationJob,
|
||
OperationJobStatus,
|
||
build_project_report,
|
||
)
|
||
from one_c_normalizer import (
|
||
COMMON_BRANCH_CHILDREN,
|
||
METADATA_CHILD_OBJECT_SPECS,
|
||
METADATA_TYPE_BY_BRANCH,
|
||
METADATA_TYPE_BY_CODE,
|
||
METADATA_TYPE_CONTEXT_ACTIONS,
|
||
METADATA_TYPE_DESCRIPTIONS,
|
||
METADATA_TYPE_DOCUMENTATION_URLS,
|
||
METADATA_TYPE_PROPERTIES,
|
||
METADATA_TYPE_SPECS,
|
||
MetadataObject,
|
||
NormalizedProject,
|
||
normalize_one_c_project,
|
||
)
|
||
from pattern_mining import SemanticPattern, mine_patterns
|
||
from projection_engine import InMemoryProjection, Neo4jProjection
|
||
from query_intelligence import table_usage
|
||
from review_engine import review_snapshot
|
||
from runtime_overlays import RuntimeOverlay, RuntimeSignal, summarize_runtime
|
||
from security_core import (
|
||
AiUsagePolicy,
|
||
InMemoryPrivacyStore,
|
||
Permission,
|
||
PrivacyClassification,
|
||
PrivacyMarker,
|
||
PrivacyMode,
|
||
UserAccess,
|
||
default_rbac_policy,
|
||
)
|
||
from semantic_search import search_snapshot
|
||
from semantic_versioning import (
|
||
InMemoryObjectVersionStore,
|
||
SemanticObjectDiff,
|
||
SemanticObjectVersion,
|
||
diff_versions,
|
||
)
|
||
from semantic_kernel import index_project
|
||
from sir import DiagnosticSeverity, EdgeKind, NodeKind, SirDelta, SirSnapshot, stable_hash
|
||
from storage_core import FileStorage, StoredSnapshotInfo
|
||
from transaction_topology import routines_touching_target, transaction_write_sets
|
||
from ui_semantics import form_semantics
|
||
|
||
app = FastAPI(title="SFERA API", version="0.1.0")
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origin_regex=os.environ.get(
|
||
"SFERA_CORS_ORIGIN_REGEX",
|
||
r"^https?://(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+|docker-test\.cin\.su)(:\d+)?$",
|
||
),
|
||
allow_credentials=False,
|
||
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
||
allow_headers=["Accept", "Content-Type"],
|
||
)
|
||
|
||
_snapshots: dict[str, SirSnapshot] = {}
|
||
_graphs: dict[str, InMemoryProjection] = {}
|
||
_neo4j_projected_projects: set[str] = set()
|
||
_overlays: dict[str, RuntimeOverlay] = {}
|
||
_knowledge = InMemoryKnowledgeBase()
|
||
_collaboration = InMemoryCollaborationStore()
|
||
_rbac = default_rbac_policy()
|
||
_privacy = InMemoryPrivacyStore()
|
||
_operations = InMemoryOperationsStore()
|
||
_versions = InMemoryObjectVersionStore()
|
||
_ai_policy = AiUsagePolicy(token_limit_per_day=100_000)
|
||
_storage = FileStorage(os.environ.get("SFERA_STORAGE_PATH", ".sfera/storage"))
|
||
_storage.initialize()
|
||
_normalized_projects: dict[str, NormalizedProject] = {}
|
||
_neo4j_uri = os.environ.get("NEO4J_URI", "bolt://docker-test.cin.su:17687")
|
||
_neo4j_user = os.environ.get("NEO4J_USER", "neo4j")
|
||
_neo4j_password = os.environ.get("NEO4J_PASSWORD", "password")
|
||
_EVENT_SUBSCRIPTION_KIND = getattr(NodeKind, "EVENT_SUBSCRIPTION", None)
|
||
|
||
_ACCESS_TARGET_KINDS = {
|
||
NodeKind.CATALOG,
|
||
NodeKind.DOCUMENT,
|
||
NodeKind.REGISTER,
|
||
NodeKind.COMMON_MODULE,
|
||
NodeKind.CONSTANT,
|
||
NodeKind.DOCUMENT_JOURNAL,
|
||
NodeKind.ENUM,
|
||
NodeKind.REPORT,
|
||
NodeKind.DATA_PROCESSOR,
|
||
NodeKind.CHART_OF_CHARACTERISTIC_TYPES,
|
||
NodeKind.CHART_OF_ACCOUNTS,
|
||
NodeKind.CHART_OF_CALCULATION_TYPES,
|
||
NodeKind.EXCHANGE_PLAN,
|
||
NodeKind.EXTERNAL_DATA_SOURCE,
|
||
NodeKind.SCHEDULED_JOB,
|
||
NodeKind.BUSINESS_PROCESS,
|
||
NodeKind.TASK,
|
||
}
|
||
if _EVENT_SUBSCRIPTION_KIND is not None:
|
||
_ACCESS_TARGET_KINDS.add(_EVENT_SUBSCRIPTION_KIND)
|
||
_OWNERSHIP_TARGET_KINDS = {
|
||
NodeKind.CATALOG,
|
||
NodeKind.DOCUMENT,
|
||
NodeKind.REGISTER,
|
||
NodeKind.COMMON_MODULE,
|
||
NodeKind.EXCHANGE_PLAN,
|
||
NodeKind.SCHEDULED_JOB,
|
||
NodeKind.BUSINESS_PROCESS,
|
||
NodeKind.TASK,
|
||
}
|
||
if _EVENT_SUBSCRIPTION_KIND is not None:
|
||
_OWNERSHIP_TARGET_KINDS.add(_EVENT_SUBSCRIPTION_KIND)
|
||
_PRIVACY_TARGET_KINDS = _OWNERSHIP_TARGET_KINDS | {
|
||
NodeKind.ATTRIBUTE,
|
||
NodeKind.TABULAR_SECTION,
|
||
NodeKind.FORM,
|
||
NodeKind.FORM_ELEMENT,
|
||
}
|
||
_SENSITIVE_NAME_HINTS = {
|
||
"адрес",
|
||
"банк",
|
||
"дата рождения",
|
||
"договор",
|
||
"документ",
|
||
"email",
|
||
"e-mail",
|
||
"инн",
|
||
"кпп",
|
||
"лицо",
|
||
"паспорт",
|
||
"почта",
|
||
"снилс",
|
||
"телефон",
|
||
"физлицо",
|
||
}
|
||
_MODULE_OWNER_NODE_KINDS = {
|
||
NodeKind.CATALOG,
|
||
NodeKind.DOCUMENT,
|
||
NodeKind.REGISTER,
|
||
NodeKind.REPORT,
|
||
NodeKind.DATA_PROCESSOR,
|
||
NodeKind.COMMON_MODULE,
|
||
NodeKind.EXCHANGE_PLAN,
|
||
NodeKind.BUSINESS_PROCESS,
|
||
NodeKind.TASK,
|
||
NodeKind.SUBSYSTEM,
|
||
NodeKind.HTTP_SERVICE,
|
||
NodeKind.XDTO_PACKAGE,
|
||
}
|
||
if _EVENT_SUBSCRIPTION_KIND is not None:
|
||
_MODULE_OWNER_NODE_KINDS.add(_EVENT_SUBSCRIPTION_KIND)
|
||
|
||
_METADATA_ICON_BY_NODE_KIND = {
|
||
NodeKind.EXCHANGE_PLAN: "exchange-plan",
|
||
NodeKind.SCHEDULED_JOB: "scheduled-job",
|
||
NodeKind.ATTRIBUTE: "attribute",
|
||
NodeKind.COMMAND: "command",
|
||
NodeKind.FORM: "form",
|
||
NodeKind.FORM_ELEMENT: "form",
|
||
NodeKind.ROLE: "role",
|
||
NodeKind.TABULAR_SECTION: "tabular",
|
||
}
|
||
if _EVENT_SUBSCRIPTION_KIND is not None:
|
||
_METADATA_ICON_BY_NODE_KIND[_EVENT_SUBSCRIPTION_KIND] = "event"
|
||
|
||
|
||
class ProjectSetupStatus(str, Enum):
|
||
NOT_CONFIGURED = "NOT_CONFIGURED"
|
||
IMPORT_REQUIRED = "IMPORT_REQUIRED"
|
||
IMPORTED = "IMPORTED"
|
||
STRUCTURE_INDEXED = "STRUCTURE_INDEXED"
|
||
INDEXED = "INDEXED"
|
||
|
||
|
||
class ImportSourceStatus(str, Enum):
|
||
AVAILABLE = "доступен"
|
||
REQUIRES_1C_PLATFORM = "требует 1С платформу"
|
||
REQUIRES_AGENT = "требует агент"
|
||
REQUIRES_CREDENTIALS = "требует учетные данные"
|
||
METADATA_ONLY = "только metadata"
|
||
FULL_IMPORT = "полный import"
|
||
|
||
|
||
class ImportSourceKind(str, Enum):
|
||
CF_FILE = "CF_FILE"
|
||
CFE_FILE = "CFE_FILE"
|
||
XML_DUMP = "XML_DUMP"
|
||
LIVE_INFOBASE = "LIVE_INFOBASE"
|
||
EPF_AGENT = "EPF_AGENT"
|
||
CFE_AGENT = "CFE_AGENT"
|
||
EDT_PROJECT = "EDT_PROJECT"
|
||
ARCHIVE_DUMP = "ARCHIVE_DUMP"
|
||
FILE_TREE = "FILE_TREE"
|
||
CONTEXT_ONLY = "CONTEXT_ONLY"
|
||
REFERENCE_CONFIGURATION = "REFERENCE_CONFIGURATION"
|
||
|
||
|
||
class ImportMode(str, Enum):
|
||
FULL_REPLACE = "FULL_REPLACE"
|
||
SYNC_PREVIEW = "SYNC_PREVIEW"
|
||
|
||
|
||
class AgentImportJobStatus(str, Enum):
|
||
QUEUED = "QUEUED"
|
||
RUNNING = "RUNNING"
|
||
SUCCEEDED = "SUCCEEDED"
|
||
FAILED = "FAILED"
|
||
CANCELLED = "CANCELLED"
|
||
|
||
|
||
class AgentImportJobRequest(BaseModel):
|
||
agent_id: str
|
||
source: ImportSourceKind
|
||
local_path: str | None = None
|
||
bin_path: str | None = None
|
||
infobase: str | None = None
|
||
credentials_ref: str | None = None
|
||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||
metadata: dict = Field(default_factory=dict)
|
||
|
||
|
||
class AgentImportJob(BaseModel):
|
||
job_id: str
|
||
project_id: str
|
||
agent_id: str
|
||
source: ImportSourceKind
|
||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||
status: AgentImportJobStatus = AgentImportJobStatus.QUEUED
|
||
local_path: str | None = None
|
||
bin_path: str | None = None
|
||
infobase: str | None = None
|
||
credentials_ref: str | None = None
|
||
metadata: dict = Field(default_factory=dict)
|
||
created_at: str
|
||
updated_at: str
|
||
claimed_at: str | None = None
|
||
completed_at: str | None = None
|
||
server_path: str | None = None
|
||
logs: list[str] = Field(default_factory=list)
|
||
error: str | None = None
|
||
import_summary: dict | None = None
|
||
|
||
|
||
class AgentImportJobResult(BaseModel):
|
||
status: AgentImportJobStatus = AgentImportJobStatus.SUCCEEDED
|
||
server_path: str | None = None
|
||
logs: list[str] = Field(default_factory=list)
|
||
error: str | None = None
|
||
metadata: dict = Field(default_factory=dict)
|
||
|
||
|
||
class AgentImportJobLogRequest(BaseModel):
|
||
logs: list[str] = Field(default_factory=list)
|
||
status: AgentImportJobStatus | None = None
|
||
|
||
|
||
class AgentBrowseRequestStatus(str, Enum):
|
||
QUEUED = "QUEUED"
|
||
RUNNING = "RUNNING"
|
||
SUCCEEDED = "SUCCEEDED"
|
||
FAILED = "FAILED"
|
||
|
||
|
||
class AgentBrowseRequestCreate(BaseModel):
|
||
agent_id: str
|
||
path: str | None = None
|
||
|
||
|
||
class AgentFolderEntry(BaseModel):
|
||
name: str
|
||
path: str
|
||
is_directory: bool = True
|
||
|
||
|
||
class AgentBrowseRequest(BaseModel):
|
||
request_id: str
|
||
agent_id: str
|
||
path: str | None = None
|
||
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.QUEUED
|
||
created_at: str
|
||
updated_at: str
|
||
claimed_at: str | None = None
|
||
completed_at: str | None = None
|
||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||
parent_path: str | None = None
|
||
error: str | None = None
|
||
|
||
|
||
class AgentBrowseResult(BaseModel):
|
||
status: AgentBrowseRequestStatus = AgentBrowseRequestStatus.SUCCEEDED
|
||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||
parent_path: str | None = None
|
||
error: str | None = None
|
||
|
||
|
||
class AgentHeartbeatRequest(BaseModel):
|
||
agent_id: str
|
||
host: str | None = None
|
||
user: str | None = None
|
||
version: str | None = None
|
||
started_at: str | None = None
|
||
network_roots: list[str] = Field(default_factory=list)
|
||
platform_bins: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class AgentStatus(BaseModel):
|
||
agent_id: str
|
||
status: str = "offline"
|
||
last_seen_at: str | None = None
|
||
host: str | None = None
|
||
user: str | None = None
|
||
version: str | None = None
|
||
started_at: str | None = None
|
||
network_roots: list[str] = Field(default_factory=list)
|
||
platform_bins: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class AgentProjectConfig(BaseModel):
|
||
project_id: str
|
||
project_name: str
|
||
one_c_bin: str | None = None
|
||
edt_path: str | None = None
|
||
default_local_path: str | None = None
|
||
network_roots: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class AgentConfigResponse(BaseModel):
|
||
agent_id: str
|
||
poll_seconds: int = 5
|
||
projects: list[AgentProjectConfig] = Field(default_factory=list)
|
||
|
||
|
||
class ServerBrowseResponse(BaseModel):
|
||
path: str
|
||
parent_path: str | None = None
|
||
entries: list[AgentFolderEntry] = Field(default_factory=list)
|
||
error: str | None = None
|
||
|
||
|
||
class ServerSmbBrowseRequest(BaseModel):
|
||
path: str
|
||
username: str | None = None
|
||
password: str | None = None
|
||
domain: str | None = None
|
||
|
||
|
||
_IMPORT_SOURCE_REGISTRY: dict[ImportSourceKind, dict] = {
|
||
ImportSourceKind.CF_FILE: {
|
||
"title": "Выгрузка конфигурации из базы 1С",
|
||
"statuses": [
|
||
ImportSourceStatus.REQUIRES_1C_PLATFORM,
|
||
ImportSourceStatus.FULL_IMPORT,
|
||
],
|
||
},
|
||
ImportSourceKind.CFE_FILE: {
|
||
"title": "Загрузка отдельного .cfe расширения",
|
||
"statuses": [
|
||
ImportSourceStatus.REQUIRES_1C_PLATFORM,
|
||
ImportSourceStatus.FULL_IMPORT,
|
||
],
|
||
},
|
||
ImportSourceKind.XML_DUMP: {
|
||
"title": "Загрузка XML dump",
|
||
"statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY],
|
||
},
|
||
ImportSourceKind.LIVE_INFOBASE: {
|
||
"title": "Подключение к live infobase через Designer CLI",
|
||
"statuses": [
|
||
ImportSourceStatus.REQUIRES_1C_PLATFORM,
|
||
ImportSourceStatus.REQUIRES_CREDENTIALS,
|
||
ImportSourceStatus.FULL_IMPORT,
|
||
],
|
||
},
|
||
ImportSourceKind.EPF_AGENT: {
|
||
"title": "Snapshot от EPF агента",
|
||
"statuses": [ImportSourceStatus.REQUIRES_AGENT, ImportSourceStatus.FULL_IMPORT],
|
||
},
|
||
ImportSourceKind.CFE_AGENT: {
|
||
"title": "Snapshot от CFE агента",
|
||
"statuses": [ImportSourceStatus.REQUIRES_AGENT, ImportSourceStatus.FULL_IMPORT],
|
||
},
|
||
ImportSourceKind.EDT_PROJECT: {
|
||
"title": "Загрузка EDT project",
|
||
"statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY],
|
||
},
|
||
ImportSourceKind.ARCHIVE_DUMP: {
|
||
"title": "Загрузка архива выгрузки конфигурации",
|
||
"statuses": [ImportSourceStatus.REQUIRES_1C_PLATFORM, ImportSourceStatus.FULL_IMPORT],
|
||
},
|
||
ImportSourceKind.FILE_TREE: {
|
||
"title": "Загрузка набора BSL/XML файлов",
|
||
"statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY],
|
||
},
|
||
ImportSourceKind.CONTEXT_ONLY: {
|
||
"title": "Context-only конфигурация для анализа экосистемы",
|
||
"statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY],
|
||
},
|
||
ImportSourceKind.REFERENCE_CONFIGURATION: {
|
||
"title": "Reference configuration для сравнения и проверки поведения",
|
||
"statuses": [ImportSourceStatus.AVAILABLE, ImportSourceStatus.METADATA_ONLY],
|
||
},
|
||
}
|
||
_project_setup: dict[str, dict] = {}
|
||
_agent_import_jobs: dict[str, "AgentImportJob"] = {}
|
||
_agent_browse_requests: dict[str, "AgentBrowseRequest"] = {}
|
||
_agent_statuses: dict[str, "AgentStatus"] = {}
|
||
ImportProgressCallback = Callable[[str, dict[str, Any]], None]
|
||
|
||
|
||
def _agent_status_with_liveness(status: "AgentStatus") -> "AgentStatus":
|
||
from datetime import datetime, timezone
|
||
|
||
if not status.last_seen_at:
|
||
status.status = "offline"
|
||
return status
|
||
try:
|
||
last_seen = datetime.fromisoformat(status.last_seen_at)
|
||
if last_seen.tzinfo is None:
|
||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||
age_seconds = (datetime.now(timezone.utc) - last_seen).total_seconds()
|
||
status.status = "online" if age_seconds <= 90 else "offline"
|
||
except ValueError:
|
||
status.status = "offline"
|
||
return status
|
||
|
||
|
||
def _agent_id_for_source(settings: "ProjectSettingsRequest", source: "ImportSourceKind") -> str:
|
||
agent = settings.agent if isinstance(settings.agent, dict) else {}
|
||
if source == ImportSourceKind.CF_FILE:
|
||
return str(agent.get("cf_agent_id") or agent.get("agent_id") or "").strip()
|
||
if source in {
|
||
ImportSourceKind.XML_DUMP,
|
||
ImportSourceKind.EDT_PROJECT,
|
||
ImportSourceKind.ARCHIVE_DUMP,
|
||
ImportSourceKind.FILE_TREE,
|
||
}:
|
||
return str(agent.get("edt_agent_id") or agent.get("agent_id") or "").strip()
|
||
return str(agent.get("agent_id") or "").strip()
|
||
|
||
|
||
def _cancel_stale_extension_install_jobs(project_id: str, selected_agent_id: str) -> None:
|
||
now = _current_timestamp()
|
||
for job in list(_agent_import_jobs.values()):
|
||
if (
|
||
job.project_id != project_id
|
||
or job.agent_id == selected_agent_id
|
||
or job.metadata.get("agent_action") != "INSTALL_SFERA_EXTENSION"
|
||
or job.status not in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING}
|
||
):
|
||
continue
|
||
job.status = AgentImportJobStatus.CANCELLED
|
||
job.updated_at = now
|
||
job.completed_at = now
|
||
job.error = (
|
||
"Старая задача установки расширения SFERA отменена: "
|
||
f"в настройках проекта выбран Windows Agent {selected_agent_id}."
|
||
)
|
||
job.logs.append(
|
||
f"Cancelled old SFERA extension install on {job.agent_id}; current selected agent is {selected_agent_id}."
|
||
)
|
||
_persist_agent_import_job(job)
|
||
|
||
|
||
def _load_persisted_state() -> None:
|
||
for payload in _storage.list_documents("project_settings"):
|
||
project_id = payload.get("project_id")
|
||
if isinstance(project_id, str):
|
||
_project_setup[project_id] = payload
|
||
for payload in _storage.list_documents("knowledge"):
|
||
_knowledge.upsert(KnowledgeRecord.model_validate(payload))
|
||
for payload in _storage.list_documents("knowledge_packs"):
|
||
_knowledge.import_pack(KnowledgePack.model_validate(payload))
|
||
for payload in _storage.list_documents("collaboration_users"):
|
||
_collaboration.upsert_user(User.model_validate(payload))
|
||
for payload in _storage.list_documents("collaboration_tasks"):
|
||
_collaboration.upsert_task(Task.model_validate(payload))
|
||
for payload in _storage.list_documents("collaboration_sessions"):
|
||
_collaboration.sessions[payload["session_id"]] = ChangeSession.model_validate(payload)
|
||
for payload in _storage.list_documents("collaboration_comments"):
|
||
comment = Comment.model_validate(payload)
|
||
_collaboration.comments[comment.comment_id] = comment
|
||
for payload in _storage.list_documents("collaboration_ownership"):
|
||
ownership = Ownership.model_validate(payload)
|
||
_collaboration.ownership[_collaboration._ownership_key(ownership)] = ownership
|
||
for payload in _storage.list_documents("operations_jobs"):
|
||
job = OperationJob.model_validate(payload)
|
||
if job.kind == "SERVER_IMPORT" and job.status in {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING}:
|
||
job.status = OperationJobStatus.FAILED
|
||
job.error = "Операция была прервана перезапуском сервера."
|
||
from datetime import datetime, timezone
|
||
|
||
job.payload = {
|
||
**job.payload,
|
||
"stage": "interrupted",
|
||
"message": "Операция была прервана перезапуском сервера.",
|
||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||
}
|
||
_storage.write_document("operations_jobs", job.job_id, job.model_dump(mode="json"))
|
||
_operations.jobs[job.job_id] = job
|
||
for payload in _storage.list_documents("agent_import_jobs"):
|
||
job = AgentImportJob.model_validate(payload)
|
||
_agent_import_jobs[job.job_id] = job
|
||
for job in list(_agent_import_jobs.values()):
|
||
if job.status not in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING}:
|
||
continue
|
||
server_import_job_id = str(job.metadata.get("server_import_job_id") or "").strip()
|
||
if not server_import_job_id:
|
||
continue
|
||
server_job = _operations.jobs.get(server_import_job_id)
|
||
if not server_job or server_job.status == OperationJobStatus.RUNNING:
|
||
continue
|
||
job.status = AgentImportJobStatus.SUCCEEDED if server_job.status == OperationJobStatus.SUCCEEDED else AgentImportJobStatus.FAILED
|
||
job.error = server_job.error
|
||
from datetime import datetime, timezone
|
||
|
||
job.completed_at = datetime.now(timezone.utc).isoformat()
|
||
job.updated_at = job.completed_at
|
||
if server_job.result.get("import_summary"):
|
||
job.import_summary = ImportSummary.model_validate(server_job.result["import_summary"])
|
||
job.logs.append(f"Server import {server_import_job_id} finished with status {server_job.status.value}.")
|
||
_storage.write_document("agent_import_jobs", job.job_id, job.model_dump(mode="json"))
|
||
for payload in _storage.list_documents("agent_browse_requests"):
|
||
browse_request = AgentBrowseRequest.model_validate(payload)
|
||
_agent_browse_requests[browse_request.request_id] = browse_request
|
||
for payload in _storage.list_documents("agent_statuses"):
|
||
status = AgentStatus.model_validate(payload)
|
||
_agent_statuses[status.agent_id] = _agent_status_with_liveness(status)
|
||
for payload in _storage.list_documents("operations_metrics"):
|
||
_operations.metrics.append(MetricSample.model_validate(payload))
|
||
for payload in _storage.list_documents("ai_usage"):
|
||
usage = AiUsageRecord.model_validate(payload)
|
||
_operations.ai_usage[usage.usage_id] = usage
|
||
for payload in _storage.list_documents("marketplace_packages"):
|
||
package = MarketplacePackage.model_validate(payload)
|
||
_operations.marketplace[package.package_id] = package
|
||
for payload in _storage.list_documents("security_users"):
|
||
access = UserAccess.model_validate(payload)
|
||
_rbac.users[access.user_id] = access
|
||
for payload in _storage.list_documents("privacy_markers"):
|
||
_privacy.upsert_marker(PrivacyMarker.model_validate(payload))
|
||
version_load_limit = int(os.environ.get("SFERA_STARTUP_OBJECT_VERSION_LIMIT", "0"))
|
||
for payload in _storage.list_documents("object_versions", limit=version_load_limit):
|
||
_versions.upsert_version(SemanticObjectVersion.model_validate(payload))
|
||
|
||
|
||
_load_persisted_state()
|
||
|
||
|
||
@app.get("/")
|
||
async def root() -> dict:
|
||
return {
|
||
"name": "SFERA API",
|
||
"status": "ok",
|
||
"docs": "/docs",
|
||
"health": "/health",
|
||
"endpoints": [
|
||
"POST /projects/index",
|
||
"POST /projects/demo/index",
|
||
"POST /projects/{project_id}/load",
|
||
"POST /projects/{project_id}/incremental/file",
|
||
"GET /storage/snapshots",
|
||
"GET /metadata/catalog",
|
||
"GET /projects/{project_id}/snapshot",
|
||
"GET /projects/{project_id}/snapshot/export",
|
||
"GET /projects/{project_id}/versions",
|
||
"GET /versions/{lineage_id}",
|
||
"GET /versions/{lineage_id}/diff",
|
||
"GET /projects/{project_id}/impact/{routine_name}",
|
||
"GET /projects/{project_id}/objects/schema/{object_name}",
|
||
"GET /projects/{project_id}/objects/attributes/{object_name}",
|
||
"GET /projects/{project_id}/objects/tabular-sections/{object_name}",
|
||
"GET /projects/{project_id}/objects/tabular-sections/{object_name}/columns",
|
||
"GET /projects/{project_id}/search",
|
||
"GET /projects/{project_id}/tables/usage",
|
||
"GET /projects/{project_id}/patterns",
|
||
"GET /projects/{project_id}/transactions/writes",
|
||
"GET /projects/{project_id}/ui/forms",
|
||
"GET /projects/{project_id}/objects/ui/{object_name}",
|
||
"GET /projects/{project_id}/jobs/scheduled",
|
||
"GET /projects/{project_id}/integrations",
|
||
"GET /projects/{project_id}/access/objects/{object_name}/roles",
|
||
"GET /projects/{project_id}/access/roles/{role_name}/objects",
|
||
"GET /graph/neo4j/status",
|
||
"POST /projects/{project_id}/graph/neo4j/project",
|
||
"GET /projects/{project_id}/graph/neo4j/callees/{routine_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/callers/{routine_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/query-tables/{routine_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/writes/{routine_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/integrations",
|
||
"GET /projects/{project_id}/graph/neo4j/integrations/{integration_name}/modules",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/schema/{object_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/attributes/{object_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}/columns",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/impact/{object_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/objects/ui/{object_name}",
|
||
"GET /projects/{project_id}/graph/neo4j/access/objects/{object_name}/roles",
|
||
"GET /projects/{project_id}/graph/neo4j/access/roles/{role_name}/objects",
|
||
"POST /knowledge",
|
||
"POST /knowledge/packs",
|
||
"GET /knowledge/packs",
|
||
"GET /knowledge/search",
|
||
"GET /projects/{project_id}/knowledge/coverage",
|
||
"GET /projects/{project_id}/knowledge/schema-coverage",
|
||
"POST /collaboration/users",
|
||
"POST /collaboration/tasks",
|
||
"POST /collaboration/sessions",
|
||
"POST /collaboration/sessions/{session_id}/finish",
|
||
"POST /projects/{project_id}/comments",
|
||
"GET /projects/{project_id}/comments",
|
||
"GET /projects/{project_id}/comments/{target_id}",
|
||
"POST /projects/{project_id}/ownership",
|
||
"GET /projects/{project_id}/ownership",
|
||
"GET /projects/{project_id}/objects/ownership/{object_name}",
|
||
"GET /projects/{project_id}/activity",
|
||
"GET /security/users/{user_id}/permissions/{permission}",
|
||
"GET /security/users/{user_id}/permissions",
|
||
"POST /security/users/{user_id}/roles/{role_id}",
|
||
"POST /projects/{project_id}/privacy/markers",
|
||
"GET /projects/{project_id}/privacy/markers",
|
||
"GET /projects/{project_id}/objects/privacy/{object_name}",
|
||
"GET /projects/{project_id}/symbols",
|
||
"GET /projects/{project_id}/symbols/definition",
|
||
"GET /projects/{project_id}/symbols/references",
|
||
"POST /operations/jobs",
|
||
"GET /operations/jobs",
|
||
"POST /operations/jobs/{job_id}/run",
|
||
"POST /operations/metrics",
|
||
"GET /operations/metrics",
|
||
"POST /ai/usage",
|
||
"GET /ai/usage",
|
||
"GET /ai/usage/summary",
|
||
"GET /ai/policy",
|
||
"POST /projects/{project_id}/ai/answer-policy",
|
||
"GET /projects/{project_id}/report",
|
||
"POST /marketplace/packages",
|
||
"GET /marketplace/packages",
|
||
"GET /license",
|
||
"GET /admin/summary",
|
||
"POST /projects/{project_id}/runtime/signals",
|
||
"GET /projects/{project_id}/runtime/summary",
|
||
],
|
||
"quick_start": {
|
||
"index_demo": "POST /projects/demo/index",
|
||
"open_docs": "/docs",
|
||
"admin_summary": "/admin/summary",
|
||
},
|
||
}
|
||
|
||
|
||
class IndexProjectRequest(BaseModel):
|
||
path: str
|
||
project_id: str | None = None
|
||
structure_only: bool = False
|
||
|
||
|
||
class ProjectSettingsRequest(BaseModel):
|
||
name: str = "SFERA Project"
|
||
configuration_source: str | None = None
|
||
structure_source: ImportSourceKind | None = None
|
||
platform_version: str | None = None
|
||
compatibility_mode: str | None = None
|
||
extensions: list[str] = Field(default_factory=list)
|
||
environments: dict = Field(default_factory=dict)
|
||
agent: dict = Field(default_factory=dict)
|
||
server_import: dict = Field(default_factory=dict)
|
||
privacy_mode: str = "METADATA_ONLY"
|
||
knowledge_sources: list[str] = Field(default_factory=list)
|
||
task_session_policy: dict = Field(default_factory=dict)
|
||
|
||
|
||
class ProjectCreateRequest(BaseModel):
|
||
project_id: str
|
||
name: str | None = None
|
||
template_project_id: str | None = None
|
||
copy_settings: bool = False
|
||
|
||
|
||
class ProjectDeleteRequest(BaseModel):
|
||
confirmation: str
|
||
delete_settings: bool = True
|
||
delete_imports: bool = True
|
||
delete_collaboration: bool = True
|
||
delete_versions: bool = True
|
||
|
||
|
||
class ProjectDeleteResponse(BaseModel):
|
||
project_id: str
|
||
deleted: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class ProjectSummaryResponse(BaseModel):
|
||
project_id: str
|
||
name: str
|
||
status: ProjectSetupStatus
|
||
has_snapshot: bool = False
|
||
|
||
|
||
class ImportSourceInfo(BaseModel):
|
||
kind: ImportSourceKind
|
||
title: str
|
||
statuses: list[ImportSourceStatus] = Field(default_factory=list)
|
||
runtime_mode: str = "mock"
|
||
available: bool = True
|
||
|
||
|
||
class ImportPreflightCheck(BaseModel):
|
||
code: str
|
||
title: str
|
||
status: str
|
||
message: str
|
||
|
||
|
||
class ImportCheckResponse(BaseModel):
|
||
project_id: str
|
||
source: ImportSourceKind
|
||
status: str
|
||
ready: bool = False
|
||
checks: list[ImportPreflightCheck] = Field(default_factory=list)
|
||
|
||
|
||
class PublishedInfobaseCheckRequest(BaseModel):
|
||
server_url: str | None = None
|
||
infobase: str | None = None
|
||
base_url: str | None = None
|
||
username: str | None = None
|
||
password: str | None = None
|
||
odata_path: str = "/odata/standard.odata"
|
||
extension_http_path: str | None = None
|
||
extension_service_root: str | None = None
|
||
extension_health_path: str | None = None
|
||
timeout_seconds: float = 10.0
|
||
|
||
|
||
class PublishedInfobaseEntity(BaseModel):
|
||
name: str
|
||
entity_type: str | None = None
|
||
|
||
|
||
class PublishedInfobaseCheckResponse(BaseModel):
|
||
project_id: str
|
||
status: str
|
||
ready: bool = False
|
||
base_url: str | None = None
|
||
odata_url: str | None = None
|
||
metadata_url: str | None = None
|
||
extension_url: str | None = None
|
||
entity_sets_count: int = 0
|
||
entity_types_count: int = 0
|
||
functions_count: int = 0
|
||
actions_count: int = 0
|
||
entity_sets: list[PublishedInfobaseEntity] = Field(default_factory=list)
|
||
checks: list[ImportPreflightCheck] = Field(default_factory=list)
|
||
|
||
|
||
class SferaExtensionCallRequest(BaseModel):
|
||
operation: str
|
||
payload: dict = Field(default_factory=dict)
|
||
dry_run: bool = True
|
||
allow_mutation: bool = False
|
||
timeout_seconds: float = 30.0
|
||
|
||
|
||
class SferaExtensionCallResponse(BaseModel):
|
||
project_id: str
|
||
operation: str
|
||
status: str
|
||
ready: bool = False
|
||
dry_run: bool = True
|
||
extension_url: str | None = None
|
||
result: dict = Field(default_factory=dict)
|
||
checks: list[ImportPreflightCheck] = Field(default_factory=list)
|
||
|
||
|
||
class ImportRequest(BaseModel):
|
||
source: ImportSourceKind
|
||
path: str | None = None
|
||
credentials_ref: str | None = None
|
||
metadata: dict = Field(default_factory=dict)
|
||
run_indexing: bool = True
|
||
structure_only: bool = False
|
||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||
|
||
|
||
class ImportSyncDiffItem(BaseModel):
|
||
qualified_name: str
|
||
name: str
|
||
object_kind: str
|
||
group_name: str | None = None
|
||
change_kind: str
|
||
before_hash: str | None = None
|
||
after_hash: str | None = None
|
||
|
||
|
||
class ImportSyncPreview(BaseModel):
|
||
mode: ImportMode = ImportMode.SYNC_PREVIEW
|
||
applied: bool = False
|
||
status: str = "preview_only"
|
||
message: str
|
||
added_count: int = 0
|
||
removed_count: int = 0
|
||
changed_count: int = 0
|
||
unchanged_count: int = 0
|
||
items: list[ImportSyncDiffItem] = Field(default_factory=list)
|
||
|
||
|
||
class NormalizedGroupSummary(BaseModel):
|
||
name: str
|
||
object_kind: str
|
||
object_count: int
|
||
|
||
|
||
class NormalizedProjectSummary(BaseModel):
|
||
project_id: str | None = None
|
||
source_path: str | None = None
|
||
group_count: int = 0
|
||
object_count: int = 0
|
||
attribute_count: int = 0
|
||
tabular_section_count: int = 0
|
||
form_count: int = 0
|
||
command_count: int = 0
|
||
role_count: int = 0
|
||
rights_count: int = 0
|
||
module_count: int = 0
|
||
layout_count: int = 0
|
||
movement_count: int = 0
|
||
extension_count: int = 0
|
||
extensions: list[str] = Field(default_factory=list)
|
||
groups: list[NormalizedGroupSummary] = Field(default_factory=list)
|
||
|
||
|
||
class NormalizedObjectDetail(BaseModel):
|
||
project_id: str | None = None
|
||
group_name: str
|
||
object: MetadataObject
|
||
|
||
|
||
class ModuleRoutineResponse(BaseModel):
|
||
name: str
|
||
kind: str
|
||
line_start: int | None = None
|
||
line_end: int | None = None
|
||
export: bool = False
|
||
calls_count: int = 0
|
||
queries_count: int = 0
|
||
writes_count: int = 0
|
||
calls: list[str] = Field(default_factory=list)
|
||
queries: list[str] = Field(default_factory=list)
|
||
writes: list[str] = Field(default_factory=list)
|
||
impact_level: str = "LOW"
|
||
impact_reasons: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class ModuleSourceResponse(BaseModel):
|
||
name: str
|
||
qualified_name: str
|
||
module_role: str = "MODULE"
|
||
source_path: str
|
||
source_text: str
|
||
routines_count: int = 0
|
||
routines: list[ModuleRoutineResponse] = Field(default_factory=list)
|
||
|
||
|
||
class BslCompletionItemResponse(BaseModel):
|
||
label: str
|
||
kind: str = "VALUE"
|
||
detail: str | None = None
|
||
insert_text: str | None = None
|
||
|
||
|
||
class ImportSummary(BaseModel):
|
||
source: ImportSourceKind
|
||
mode: ImportMode = ImportMode.FULL_REPLACE
|
||
applied: bool = True
|
||
status: str
|
||
last_import: str
|
||
source_path: str | None = None
|
||
runtime_mode: str = "mock"
|
||
runtime_diagnostics: list[str] = Field(default_factory=list)
|
||
errors: list[str] = Field(default_factory=list)
|
||
diagnostics_count: int = 0
|
||
diagnostics: list[str] = Field(default_factory=list)
|
||
object_count: int = 0
|
||
module_count: int = 0
|
||
form_count: int = 0
|
||
role_count: int = 0
|
||
extensions: list[str] = Field(default_factory=list)
|
||
platform_version: str | None = None
|
||
compatibility_mode: str | None = None
|
||
snapshot: SnapshotSummary | None = None
|
||
normalized_summary: NormalizedProjectSummary | None = None
|
||
sync_preview: ImportSyncPreview | None = None
|
||
|
||
|
||
class ImportQualityCheck(BaseModel):
|
||
code: str
|
||
title: str
|
||
severity: str = "INFO"
|
||
passed: bool = True
|
||
message: str
|
||
value: int | str | None = None
|
||
|
||
|
||
class ImportQualityResponse(BaseModel):
|
||
project_id: str
|
||
status: str
|
||
score: int = 0
|
||
ready_for_ide: bool = False
|
||
summary: NormalizedProjectSummary | None = None
|
||
checks: list[ImportQualityCheck] = Field(default_factory=list)
|
||
|
||
|
||
class ProjectSetupResponse(BaseModel):
|
||
project_id: str
|
||
status: ProjectSetupStatus
|
||
message: str
|
||
settings: ProjectSettingsRequest
|
||
current_source: ImportSourceKind | None = None
|
||
last_import: ImportSummary | None = None
|
||
import_history: list[ImportSummary] = Field(default_factory=list)
|
||
import_sources: list[ImportSourceInfo] = Field(default_factory=list)
|
||
|
||
|
||
class IncrementalFileRequest(BaseModel):
|
||
path: str
|
||
|
||
|
||
class SnapshotSummary(BaseModel):
|
||
snapshot_id: str
|
||
project_id: str
|
||
snapshot_hash: str | None
|
||
node_count: int
|
||
edge_count: int
|
||
diagnostics_count: int
|
||
unresolved_references_count: int
|
||
|
||
|
||
class IndexProjectResponse(BaseModel):
|
||
snapshot: SnapshotSummary
|
||
|
||
|
||
class IncrementalFileResponse(BaseModel):
|
||
snapshot: SnapshotSummary
|
||
added_nodes: int
|
||
updated_nodes: int
|
||
removed_nodes: int
|
||
added_edges: int
|
||
removed_edges: int
|
||
neo4j_projected: bool = False
|
||
neo4j_error: str | None = None
|
||
|
||
|
||
class MetadataTypeSpecResponse(BaseModel):
|
||
code: str
|
||
russian_name: str
|
||
tree_branch: str
|
||
icon: str
|
||
description: str = ""
|
||
documentation_url: str = ""
|
||
child_groups: list[str] = Field(default_factory=list)
|
||
module_kinds: list[str] = Field(default_factory=list)
|
||
properties: list[str] = Field(default_factory=list)
|
||
context_actions: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class MetadataChildObjectSpecResponse(BaseModel):
|
||
code: str
|
||
russian_name: str
|
||
parent_groups: list[str] = Field(default_factory=list)
|
||
description: str = ""
|
||
documentation_url: str = ""
|
||
|
||
|
||
class MetadataCatalogResponse(BaseModel):
|
||
platform_family: str
|
||
source: str
|
||
common_branch_children: list[str] = Field(default_factory=list)
|
||
types: list[MetadataTypeSpecResponse] = Field(default_factory=list)
|
||
child_object_types: list[MetadataChildObjectSpecResponse] = Field(default_factory=list)
|
||
|
||
|
||
class MetadataTreeNodeResponse(BaseModel):
|
||
id: str
|
||
label: str
|
||
kind: str
|
||
icon: str
|
||
qualified_name: str | None = None
|
||
count: int = 0
|
||
loaded_count: int = 0
|
||
has_more: bool = False
|
||
children: list["MetadataTreeNodeResponse"] = Field(default_factory=list)
|
||
|
||
|
||
class ProjectMetadataTreeResponse(BaseModel):
|
||
project_id: str
|
||
root: MetadataTreeNodeResponse
|
||
|
||
|
||
class MetadataTreeChildrenResponse(BaseModel):
|
||
project_id: str
|
||
parent_id: str
|
||
offset: int = 0
|
||
limit: int = 50
|
||
total: int = 0
|
||
has_more: bool = False
|
||
children: list[MetadataTreeNodeResponse] = Field(default_factory=list)
|
||
|
||
|
||
class MetadataTreeSearchResponse(BaseModel):
|
||
project_id: str
|
||
q: str
|
||
total: int = 0
|
||
results: list[MetadataTreeNodeResponse] = Field(default_factory=list)
|
||
|
||
|
||
class MetadataTreePathStepResponse(BaseModel):
|
||
parent_id: str
|
||
child_id: str
|
||
offset: int = 0
|
||
|
||
|
||
class MetadataTreePathResponse(BaseModel):
|
||
project_id: str
|
||
node_id: str
|
||
path: list[str] = Field(default_factory=list)
|
||
steps: list[MetadataTreePathStepResponse] = Field(default_factory=list)
|
||
|
||
|
||
class FlowchartNodeResponse(BaseModel):
|
||
id: str
|
||
label: str
|
||
kind: str
|
||
qualified_name: str | None = None
|
||
count: int = 1
|
||
level: int = 0
|
||
expandable: bool = False
|
||
|
||
|
||
class FlowchartEdgeResponse(BaseModel):
|
||
id: str
|
||
source: str
|
||
target: str
|
||
kind: str
|
||
label: str
|
||
count: int = 1
|
||
|
||
|
||
class ProjectFlowchartResponse(BaseModel):
|
||
project_id: str
|
||
mode: str
|
||
focus: str | None = None
|
||
total_nodes: int
|
||
total_edges: int
|
||
nodes: list[FlowchartNodeResponse] = Field(default_factory=list)
|
||
edges: list[FlowchartEdgeResponse] = Field(default_factory=list)
|
||
|
||
|
||
class NamedNode(BaseModel):
|
||
lineage_id: str
|
||
kind: str
|
||
name: str
|
||
qualified_name: str
|
||
|
||
|
||
class ImpactResponse(BaseModel):
|
||
routine_name: str
|
||
callers: list[NamedNode] = Field(default_factory=list)
|
||
callees: list[NamedNode] = Field(default_factory=list)
|
||
query_tables: list[NamedNode] = Field(default_factory=list)
|
||
writes: list[NamedNode] = Field(default_factory=list)
|
||
|
||
|
||
class RoleAccessResponse(BaseModel):
|
||
role: NamedNode
|
||
permissions: dict = Field(default_factory=dict)
|
||
|
||
|
||
class ObjectAccessResponse(BaseModel):
|
||
object: NamedNode
|
||
grants: list[RoleAccessResponse] = Field(default_factory=list)
|
||
|
||
|
||
class RoleObjectAccessResponse(BaseModel):
|
||
role: NamedNode
|
||
objects: list[NamedNode] = Field(default_factory=list)
|
||
grants: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
class ObjectImpactResponse(BaseModel):
|
||
object_name: str
|
||
object: NamedNode
|
||
modules: list[NamedNode] = Field(default_factory=list)
|
||
routines: list[NamedNode] = Field(default_factory=list)
|
||
forms: list[NamedNode] = Field(default_factory=list)
|
||
commands: list[NamedNode] = Field(default_factory=list)
|
||
attributes: list[NamedNode] = Field(default_factory=list)
|
||
tabular_sections: list[NamedNode] = Field(default_factory=list)
|
||
tabular_section_columns: dict[str, list[NamedNode]] = Field(default_factory=dict)
|
||
roles: list[NamedNode] = Field(default_factory=list)
|
||
role_access: list[RoleAccessResponse] = Field(default_factory=list)
|
||
jobs: list[NamedNode] = Field(default_factory=list)
|
||
callees: list[NamedNode] = Field(default_factory=list)
|
||
query_tables: list[NamedNode] = Field(default_factory=list)
|
||
writes: list[NamedNode] = Field(default_factory=list)
|
||
|
||
|
||
class SearchResponse(BaseModel):
|
||
results: list[NamedNode]
|
||
|
||
|
||
class SourceLocationResponse(BaseModel):
|
||
source_path: str | None = None
|
||
line_start: int | None = None
|
||
line_end: int | None = None
|
||
column_start: int | None = None
|
||
column_end: int | None = None
|
||
|
||
|
||
class SymbolResponse(BaseModel):
|
||
node: NamedNode
|
||
source: SourceLocationResponse
|
||
|
||
|
||
class SymbolReferenceResponse(BaseModel):
|
||
edge_id: str
|
||
kind: str
|
||
direction: str
|
||
source: NamedNode | None = None
|
||
target: NamedNode | None = None
|
||
location: SourceLocationResponse | None = None
|
||
attributes: dict = Field(default_factory=dict)
|
||
|
||
|
||
class SymbolReferencesResponse(BaseModel):
|
||
symbol: SymbolResponse
|
||
references: list[SymbolReferenceResponse] = Field(default_factory=list)
|
||
|
||
|
||
class TabularSectionColumnsResponse(BaseModel):
|
||
tabular_section: NamedNode
|
||
columns: list[NamedNode] = Field(default_factory=list)
|
||
|
||
|
||
class ObjectSchemaResponse(BaseModel):
|
||
object: NamedNode
|
||
attributes: list[NamedNode] = Field(default_factory=list)
|
||
tabular_sections: list[TabularSectionColumnsResponse] = Field(default_factory=list)
|
||
|
||
|
||
class TableUsageResponse(BaseModel):
|
||
table: NamedNode
|
||
queries: list[NamedNode]
|
||
readers: list[NamedNode]
|
||
writers: list[NamedNode]
|
||
|
||
|
||
class TransactionWriteSetResponse(BaseModel):
|
||
routine: NamedNode
|
||
writes: list[NamedNode]
|
||
|
||
|
||
class FormSemanticsResponse(BaseModel):
|
||
form: NamedNode
|
||
commands: list[NamedNode]
|
||
elements: list[NamedNode]
|
||
command_handlers: dict[str, NamedNode] = Field(default_factory=dict)
|
||
|
||
|
||
class ObjectUiResponse(BaseModel):
|
||
object: NamedNode
|
||
forms: list[FormSemanticsResponse] = Field(default_factory=list)
|
||
|
||
|
||
class ScheduledJobResponse(BaseModel):
|
||
job_id: str
|
||
name: str
|
||
routine_name: str
|
||
schedule: str | None = None
|
||
routine: NamedNode | None = None
|
||
attributes: dict = Field(default_factory=dict)
|
||
|
||
|
||
class IntegrationEndpointResponse(BaseModel):
|
||
endpoint_id: str
|
||
name: str
|
||
kind: str
|
||
direction: str
|
||
owner: str | None = None
|
||
attributes: dict = Field(default_factory=dict)
|
||
|
||
|
||
class RuntimeSignalRequest(BaseModel):
|
||
signal: RuntimeSignal
|
||
|
||
|
||
class RuntimeSummaryResponse(BaseModel):
|
||
node: NamedNode
|
||
signal_count: int
|
||
error_count: int
|
||
max_duration_ms: float | None = None
|
||
|
||
|
||
class AiPolicyResponse(BaseModel):
|
||
policy: AiUsagePolicy
|
||
used_tokens: int
|
||
remaining_tokens: int | None = None
|
||
|
||
|
||
class AiAnswerPolicyRequest(BaseModel):
|
||
user_id: str
|
||
question: str
|
||
model: str | None = None
|
||
operation: str = "answer"
|
||
related_lineages: list[str] = Field(default_factory=list)
|
||
estimated_tokens: int = 0
|
||
require_knowledge: bool = True
|
||
|
||
|
||
class AiAnswerPolicyDecision(BaseModel):
|
||
allowed: bool
|
||
reasons: list[str] = Field(default_factory=list)
|
||
warnings: list[str] = Field(default_factory=list)
|
||
knowledge_records: list[KnowledgeRecord] = Field(default_factory=list)
|
||
privacy_markers: list[PrivacyMarker] = Field(default_factory=list)
|
||
used_tokens: int
|
||
remaining_tokens: int | None = None
|
||
policy: AiUsagePolicy
|
||
|
||
|
||
class AuthoringContextRequest(BaseModel):
|
||
object_name: str | None = None
|
||
routine_name: str | None = None
|
||
cursor_line: int | None = None
|
||
source_text: str | None = None
|
||
prefix: str = ""
|
||
|
||
|
||
class AuthoringContextResponse(BaseModel):
|
||
project_id: str
|
||
object: NamedNode | None = None
|
||
routine: NamedNode | None = None
|
||
local_variables: list[str] = Field(default_factory=list)
|
||
parameters: list[str] = Field(default_factory=list)
|
||
object_attributes: list[NamedNode] = Field(default_factory=list)
|
||
tabular_sections: list[NamedNode] = Field(default_factory=list)
|
||
form_elements: list[NamedNode] = Field(default_factory=list)
|
||
commands: list[NamedNode] = Field(default_factory=list)
|
||
query_tables: list[NamedNode] = Field(default_factory=list)
|
||
writes: list[NamedNode] = Field(default_factory=list)
|
||
available_methods: list[str] = Field(default_factory=list)
|
||
privacy_markers: list[PrivacyMarker] = Field(default_factory=list)
|
||
review_findings: list[dict] = Field(default_factory=list)
|
||
|
||
|
||
class AuthoringDiffLine(BaseModel):
|
||
kind: str
|
||
text: str
|
||
|
||
|
||
class AuthoringGuardCheck(BaseModel):
|
||
name: str
|
||
status: str
|
||
message: str
|
||
|
||
|
||
class AuthoringCompletionPreviewRequest(AuthoringContextRequest):
|
||
intent: str = "guarded-return"
|
||
estimated_tokens: int = 512
|
||
user_id: str | None = None
|
||
|
||
|
||
class AuthoringCompletionPreviewResponse(BaseModel):
|
||
allowed: bool
|
||
insert_text: str
|
||
semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list)
|
||
checks: list[AuthoringGuardCheck] = Field(default_factory=list)
|
||
context: AuthoringContextResponse
|
||
|
||
|
||
class AuthoringSemanticDiffPreviewRequest(BaseModel):
|
||
target_lineage_id: str | None = None
|
||
object_name: str | None = None
|
||
routine_name: str | None = None
|
||
source_path: str | None = None
|
||
original_text: str
|
||
proposed_text: str
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
user_id: str | None = None
|
||
estimated_tokens: int = 0
|
||
|
||
|
||
class AuthoringVersionPreview(BaseModel):
|
||
lineage_id: str
|
||
semantic_id: str
|
||
current_version_id: str | None = None
|
||
next_version_id: str
|
||
object_hash: str
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
apply_available: bool = False
|
||
|
||
|
||
class AuthoringSemanticDiffPreviewResponse(BaseModel):
|
||
project_id: str
|
||
target: NamedNode | None = None
|
||
changed: bool
|
||
added_lines: int
|
||
removed_lines: int
|
||
semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list)
|
||
affected_nodes: list[NamedNode] = Field(default_factory=list)
|
||
checks: list[AuthoringGuardCheck] = Field(default_factory=list)
|
||
version_preview: AuthoringVersionPreview | None = None
|
||
|
||
|
||
class AuthoringApplyChangeSetRequest(AuthoringSemanticDiffPreviewRequest):
|
||
expected_next_version_id: str
|
||
approved_by: str
|
||
approval_note: str | None = None
|
||
apply_to_production: bool = False
|
||
|
||
|
||
class AuthoringApplyChangeSetResponse(BaseModel):
|
||
project_id: str
|
||
status: str
|
||
change_id: str
|
||
version: SemanticObjectVersion
|
||
preview: AuthoringSemanticDiffPreviewResponse
|
||
persisted_path: str
|
||
production_applied: bool = False
|
||
|
||
|
||
class AuthoringChangeSummary(BaseModel):
|
||
change_id: str
|
||
project_id: str
|
||
status: str
|
||
target: NamedNode | None = None
|
||
version_id: str
|
||
approved_by: str
|
||
approval_note: str | None = None
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
added_lines: int
|
||
removed_lines: int
|
||
production_applied: bool = False
|
||
|
||
|
||
class AuthoringRollbackPreviewResponse(BaseModel):
|
||
project_id: str
|
||
change_id: str
|
||
original_version_id: str
|
||
rollback_version_id: str
|
||
target: NamedNode | None = None
|
||
semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list)
|
||
checks: list[AuthoringGuardCheck] = Field(default_factory=list)
|
||
apply_available: bool = False
|
||
|
||
|
||
class AuthoringApplyRollbackRequest(BaseModel):
|
||
expected_rollback_version_id: str
|
||
approved_by: str
|
||
approval_note: str | None = None
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
apply_to_production: bool = False
|
||
|
||
|
||
class AuthoringApplyRollbackResponse(BaseModel):
|
||
project_id: str
|
||
status: str
|
||
change_id: str
|
||
rollback_change_id: str
|
||
version: SemanticObjectVersion
|
||
preview: AuthoringRollbackPreviewResponse
|
||
persisted_path: str
|
||
production_applied: bool = False
|
||
|
||
|
||
class AuthoringMetadataAttributeDraft(BaseModel):
|
||
name: str
|
||
type: str = "Строка"
|
||
synonym: str | None = None
|
||
required: bool = False
|
||
|
||
|
||
class AuthoringMetadataTabularSectionDraft(BaseModel):
|
||
name: str
|
||
synonym: str | None = None
|
||
attributes: list[AuthoringMetadataAttributeDraft] = Field(default_factory=list)
|
||
|
||
|
||
class AuthoringMetadataCommandDraft(BaseModel):
|
||
name: str
|
||
handler: str | None = None
|
||
|
||
|
||
class AuthoringMetadataObjectPreviewRequest(BaseModel):
|
||
object_kind: str
|
||
name: str
|
||
synonym: str | None = None
|
||
attributes: list[AuthoringMetadataAttributeDraft] = Field(default_factory=list)
|
||
tabular_sections: list[AuthoringMetadataTabularSectionDraft] = Field(default_factory=list)
|
||
forms: list[str] = Field(default_factory=list)
|
||
commands: list[AuthoringMetadataCommandDraft] = Field(default_factory=list)
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
user_id: str | None = None
|
||
|
||
|
||
class AuthoringMetadataObjectPreviewResponse(BaseModel):
|
||
project_id: str
|
||
target: NamedNode
|
||
changed: bool
|
||
added_lines: int
|
||
removed_lines: int = 0
|
||
semantic_diff: list[AuthoringDiffLine] = Field(default_factory=list)
|
||
checks: list[AuthoringGuardCheck] = Field(default_factory=list)
|
||
version_preview: AuthoringVersionPreview
|
||
|
||
|
||
class AuthoringApplyMetadataObjectRequest(AuthoringMetadataObjectPreviewRequest):
|
||
expected_next_version_id: str
|
||
approved_by: str
|
||
approval_note: str | None = None
|
||
apply_to_production: bool = False
|
||
|
||
|
||
class AuthoringApplyMetadataObjectResponse(BaseModel):
|
||
project_id: str
|
||
status: str
|
||
change_id: str
|
||
version: SemanticObjectVersion
|
||
preview: AuthoringMetadataObjectPreviewResponse
|
||
persisted_path: str
|
||
production_applied: bool = False
|
||
|
||
|
||
class KnowledgeSearchResponse(BaseModel):
|
||
results: list[KnowledgeRecord]
|
||
|
||
|
||
class KnowledgeCoverageResponse(BaseModel):
|
||
node: NamedNode
|
||
record_count: int
|
||
|
||
|
||
class KnowledgeSchemaCoverageResponse(BaseModel):
|
||
items: list[KnowledgeCoverageResponse]
|
||
uncovered: list[NamedNode]
|
||
covered_count: int
|
||
uncovered_count: int
|
||
|
||
|
||
class CollaborationSessionRequest(BaseModel):
|
||
session: ChangeSession
|
||
|
||
|
||
class CommentRequest(BaseModel):
|
||
comment_id: str
|
||
target_id: str
|
||
user_id: str
|
||
body: str
|
||
|
||
|
||
class OwnershipRequest(BaseModel):
|
||
target_id: str
|
||
owner_user_id: str
|
||
role: str = "OWNER"
|
||
assigned_by: str | None = None
|
||
attributes: dict = Field(default_factory=dict)
|
||
|
||
|
||
class ObjectOwnershipResponse(BaseModel):
|
||
object: NamedNode
|
||
owners: list[Ownership] = Field(default_factory=list)
|
||
|
||
|
||
class PrivacyMarkerRequest(BaseModel):
|
||
target_id: str
|
||
classification: PrivacyClassification
|
||
reason: str | None = None
|
||
attributes: dict = Field(default_factory=dict)
|
||
|
||
|
||
class ObjectPrivacyResponse(BaseModel):
|
||
object: NamedNode
|
||
markers: list[PrivacyMarker] = Field(default_factory=list)
|
||
|
||
|
||
class JobUpdateRequest(BaseModel):
|
||
status: OperationJobStatus
|
||
result: dict = Field(default_factory=dict)
|
||
error: str | None = None
|
||
|
||
|
||
class Neo4jProjectionResponse(BaseModel):
|
||
project_id: str
|
||
nodes: int
|
||
edges: int
|
||
status: str
|
||
|
||
|
||
class ObjectVersionSummary(BaseModel):
|
||
version_id: str
|
||
lineage_id: str
|
||
semantic_id: str
|
||
object_hash: str
|
||
parent_version_id: str | None = None
|
||
task_id: str | None = None
|
||
session_id: str | None = None
|
||
|
||
|
||
@app.get("/health")
|
||
async def health() -> dict[str, str]:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def api_health() -> dict[str, str]:
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/html5")
|
||
async def html5_index() -> Response:
|
||
return Response(
|
||
render_html5_index(_project_summaries()),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.post("/html5/projects")
|
||
async def html5_create_project(request: Request) -> Response:
|
||
form = await _html5_form_data(request)
|
||
project_id = _form_value(form, "project_id")
|
||
if not project_id:
|
||
raise HTTPException(status_code=400, detail="project_id is required.")
|
||
await create_project(ProjectCreateRequest(project_id=project_id, name=_form_value(form, "name")))
|
||
return Response(
|
||
render_html5_project_rows(_project_summaries()),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.get("/html5/projects/{project_id}/editor")
|
||
async def html5_project_editor(project_id: str, q: str = "") -> Response:
|
||
try:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
html = render_html5_editor(
|
||
project_id=project_id,
|
||
projects=_project_summaries(),
|
||
snapshot=snapshot,
|
||
q=q,
|
||
)
|
||
except HTTPException as error:
|
||
html = render_html5_editor(
|
||
project_id=project_id,
|
||
projects=_project_summaries(),
|
||
snapshot=None,
|
||
error=str(error.detail),
|
||
q=q,
|
||
)
|
||
return Response(html, media_type="text/html; charset=utf-8")
|
||
|
||
|
||
@app.get("/html5/projects/{project_id}/events")
|
||
async def html5_project_events(project_id: str, once: bool = False) -> StreamingResponse:
|
||
def stream_status():
|
||
while True:
|
||
try:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
fragment = render_html5_status(project_id, snapshot)
|
||
except HTTPException as error:
|
||
fragment = f'<span>project: {project_id}</span><span>error: {error.detail}</span>'
|
||
yield f"event: status\ndata: {fragment}\n\n"
|
||
if once:
|
||
break
|
||
time.sleep(5)
|
||
|
||
return StreamingResponse(
|
||
stream_status(),
|
||
media_type="text/event-stream",
|
||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||
)
|
||
|
||
|
||
@app.get("/html5/projects/{project_id}/symbols")
|
||
async def html5_project_symbols(project_id: str, q: str = "") -> Response:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return Response(
|
||
render_html5_symbols(snapshot, q),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.get("/html5/projects/{project_id}/source/{lineage_id}")
|
||
async def html5_project_source(project_id: str, lineage_id: str) -> Response:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
node = _find_snapshot_node(snapshot, lineage_id)
|
||
if node is None:
|
||
raise HTTPException(status_code=404, detail=f"Lineage not found: {lineage_id}")
|
||
return Response(
|
||
render_html5_source(node),
|
||
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)
|
||
return Response(
|
||
render_html5_project_setup(project_id=project_id, projects=_project_summaries(), setup=setup),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.get("/html5/projects/{project_id}/setup/summary")
|
||
async def html5_project_setup_summary(project_id: str) -> Response:
|
||
setup = _project_setup_response(project_id)
|
||
return Response(
|
||
render_html5_setup_summary(project_id, setup),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.post("/html5/projects/{project_id}/setup/source")
|
||
async def html5_project_setup_source(project_id: str, request: Request) -> Response:
|
||
form = await _html5_form_data(request)
|
||
source = ImportSourceKind(_form_value(form, "source") or ImportSourceKind.XML_DUMP.value)
|
||
current = _project_setup_response(project_id)
|
||
settings = current.settings.model_copy(update={"structure_source": source})
|
||
setup = await save_project_settings(project_id, settings)
|
||
return Response(
|
||
render_html5_setup_summary(project_id, setup),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.post("/html5/projects/{project_id}/setup/check")
|
||
async def html5_project_setup_check(project_id: str, request: Request) -> Response:
|
||
form = await _html5_form_data(request)
|
||
source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value)
|
||
check = _import_check_response(project_id, source, ImportRequest(source=source))
|
||
return Response(
|
||
render_html5_import_check(project_id, check),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.post("/html5/projects/{project_id}/setup/import")
|
||
async def html5_project_setup_import(project_id: str, request: Request) -> Response:
|
||
form = await _html5_form_data(request)
|
||
source = ImportSourceKind(_form_value(form, "source") or _current_import_source(project_id).value)
|
||
structure_only = _form_value(form, "structure_only") in {"1", "true", "on", "yes"}
|
||
_execute_import_project(project_id, ImportRequest(source=source, structure_only=structure_only))
|
||
setup = _project_setup_response(project_id)
|
||
return Response(
|
||
render_html5_setup_summary(project_id, setup),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.post("/html5/projects/{project_id}/setup/reindex")
|
||
async def html5_project_setup_reindex(project_id: str) -> Response:
|
||
await reindex_project(project_id)
|
||
setup = _project_setup_response(project_id)
|
||
return Response(
|
||
render_html5_setup_summary(project_id, setup),
|
||
media_type="text/html; charset=utf-8",
|
||
)
|
||
|
||
|
||
@app.get("/version")
|
||
async def version() -> dict[str, str]:
|
||
return {"name": "sfera", "version": "0.1.0"}
|
||
|
||
|
||
@app.get("/import-sources", response_model=list[ImportSourceInfo])
|
||
async def import_sources() -> list[ImportSourceInfo]:
|
||
return _import_source_infos()
|
||
|
||
|
||
@app.get("/projects", response_model=list[ProjectSummaryResponse])
|
||
async def list_projects() -> list[ProjectSummaryResponse]:
|
||
return _project_summaries()
|
||
|
||
|
||
@app.get("/projects/{project_id}/setup", response_model=ProjectSetupResponse)
|
||
async def project_setup(project_id: str) -> ProjectSetupResponse:
|
||
return _project_setup_response(project_id)
|
||
|
||
|
||
@app.post("/projects", response_model=ProjectSetupResponse)
|
||
async def create_project(request: ProjectCreateRequest) -> ProjectSetupResponse:
|
||
project_id = _normalize_project_id(request.project_id)
|
||
if not project_id:
|
||
raise HTTPException(status_code=400, detail="project_id is required.")
|
||
if project_id in _project_setup or _storage.has_snapshot(project_id):
|
||
raise HTTPException(status_code=409, detail=f"Project already exists: {project_id}")
|
||
settings = ProjectSettingsRequest(name=request.name or project_id)
|
||
if request.copy_settings and request.template_project_id:
|
||
template = _project_setup.get(request.template_project_id, {}).get("settings", {})
|
||
settings = ProjectSettingsRequest.model_validate({**template, "name": request.name or project_id})
|
||
_project_setup[project_id] = {
|
||
"project_id": project_id,
|
||
"settings": settings.model_dump(mode="json"),
|
||
"status": ProjectSetupStatus.NOT_CONFIGURED.value,
|
||
"import_history": [],
|
||
}
|
||
_storage.write_document("project_settings", project_id, _project_setup[project_id])
|
||
return _project_setup_response(project_id)
|
||
|
||
|
||
@app.post("/projects/{project_id}/settings", response_model=ProjectSetupResponse)
|
||
async def save_project_settings(project_id: str, request: ProjectSettingsRequest) -> ProjectSetupResponse:
|
||
state = _project_setup.setdefault(project_id, {})
|
||
state["project_id"] = project_id
|
||
state["settings"] = request.model_dump(mode="json")
|
||
if request.structure_source is not None:
|
||
state["current_source"] = request.structure_source.value
|
||
state["status"] = ProjectSetupStatus.IMPORT_REQUIRED.value
|
||
else:
|
||
state.setdefault("status", ProjectSetupStatus.NOT_CONFIGURED.value)
|
||
_storage.write_document("project_settings", project_id, state)
|
||
return _project_setup_response(project_id)
|
||
|
||
|
||
@app.post("/projects/{project_id}/published-infobase/check", response_model=PublishedInfobaseCheckResponse)
|
||
def check_published_infobase(
|
||
project_id: str,
|
||
request: PublishedInfobaseCheckRequest,
|
||
) -> PublishedInfobaseCheckResponse:
|
||
if project_id not in _project_setup and not _storage.has_snapshot(project_id):
|
||
raise HTTPException(status_code=404, detail=f"Project not found: {project_id}")
|
||
settings = ProjectSettingsRequest.model_validate(_project_setup.get(project_id, {}).get("settings", {}))
|
||
return _check_published_infobase(project_id, settings, request)
|
||
|
||
|
||
@app.post("/projects/{project_id}/live/extension/call", response_model=SferaExtensionCallResponse)
|
||
def call_sfera_extension(project_id: str, request: SferaExtensionCallRequest) -> SferaExtensionCallResponse:
|
||
settings = _project_settings_or_404(project_id)
|
||
return _call_sfera_extension(project_id, settings, request)
|
||
|
||
|
||
@app.post("/projects/{project_id}/live/extension/health", response_model=SferaExtensionCallResponse)
|
||
def sfera_extension_health(project_id: str) -> SferaExtensionCallResponse:
|
||
settings = _project_settings_or_404(project_id)
|
||
return _call_sfera_extension(project_id, settings, SferaExtensionCallRequest(operation="health", dry_run=True))
|
||
|
||
|
||
@app.post("/projects/{project_id}/live/extension/metadata-snapshot", response_model=SferaExtensionCallResponse)
|
||
def sfera_extension_metadata_snapshot(project_id: str) -> SferaExtensionCallResponse:
|
||
settings = _project_settings_or_404(project_id)
|
||
return _call_sfera_extension(project_id, settings, SferaExtensionCallRequest(operation="metadata.snapshot", dry_run=True))
|
||
|
||
|
||
@app.post("/projects/{project_id}/live/extension/query", response_model=SferaExtensionCallResponse)
|
||
def sfera_extension_query(project_id: str, request: SferaExtensionCallRequest) -> SferaExtensionCallResponse:
|
||
settings = _project_settings_or_404(project_id)
|
||
return _call_sfera_extension(project_id, settings, request.model_copy(update={"operation": "query.execute"}))
|
||
|
||
|
||
@app.get("/projects/{project_id}/live/extension/package.zip")
|
||
def download_sfera_extension_package(project_id: str) -> Response:
|
||
_project_settings_or_404(project_id)
|
||
package = _build_sfera_extension_package()
|
||
return Response(
|
||
content=package,
|
||
media_type="application/zip",
|
||
headers={"Content-Disposition": 'attachment; filename="sfera-extension-source.zip"'},
|
||
)
|
||
|
||
|
||
@app.post("/projects/{project_id}/live/extension/install-agent-job", response_model=AgentImportJob)
|
||
async def create_sfera_extension_install_job(project_id: str, request: Request) -> AgentImportJob:
|
||
settings = _project_settings_or_404(project_id)
|
||
agent = dict(settings.agent or {})
|
||
agent_id = (_agent_string_value(agent, "cf_agent_id") or _agent_string_value(agent, "agent_id")).strip()
|
||
agent_status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id))) if agent_id else None
|
||
if not agent_id:
|
||
raise HTTPException(status_code=400, detail="Select Windows Agent for CF/CFE or common agent settings before installing SFERA extension.")
|
||
if agent_status is None or agent_status.status != "online":
|
||
raise HTTPException(status_code=409, detail=f"Windows Agent {agent_id} is offline. Start agent and wait for heartbeat.")
|
||
_cancel_stale_extension_install_jobs(project_id, agent_id)
|
||
|
||
one_c_server = _agent_string_value(agent, "one_c_server") or _agent_string_value(agent, "published_1c_server") or _agent_string_value(agent, "published_server_url")
|
||
one_c_infobase = _agent_string_value(agent, "one_c_infobase") or _agent_string_value(agent, "published_infobase")
|
||
if one_c_server.startswith(("http://", "https://")):
|
||
one_c_server = urlsplit(one_c_server).hostname or one_c_server
|
||
if not one_c_server or not one_c_infobase:
|
||
raise HTTPException(status_code=400, detail="Fill 1C server and infobase in project settings before installing SFERA extension.")
|
||
|
||
active = [
|
||
job
|
||
for job in _agent_import_jobs.values()
|
||
if job.project_id == project_id
|
||
and job.metadata.get("agent_action") == "INSTALL_SFERA_EXTENSION"
|
||
and job.status in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING}
|
||
]
|
||
if active:
|
||
running = max(active, key=lambda item: item.updated_at)
|
||
raise HTTPException(status_code=409, detail=f"SFERA extension install is already running as {running.job_id}.")
|
||
|
||
now = _current_timestamp()
|
||
cfe_path = _agent_string_value(agent, "sfera_extension_cfe_path")
|
||
package_url = f"{_public_api_origin(request)}/projects/{quote(project_id)}/live/extension/package.zip"
|
||
metadata = {
|
||
"agent_action": "INSTALL_SFERA_EXTENSION",
|
||
"extension_name": _agent_string_value(agent, "sfera_extension_name") or "SFERA",
|
||
"force_reinstall": True,
|
||
"extension_cfe_path": cfe_path or None,
|
||
"extension_package_url": package_url,
|
||
"one_c_bin": _agent_string_value(agent, "one_c_bin") or None,
|
||
"one_c_server": one_c_server,
|
||
"one_c_infobase": one_c_infobase,
|
||
"one_c_user": _agent_string_value(agent, "one_c_user") or _agent_string_value(agent, "published_user") or None,
|
||
"one_c_password": _agent_string_value(agent, "one_c_password") or _agent_string_value(agent, "published_password") or None,
|
||
"published_base_url": _agent_string_value(agent, "published_base_url") or None,
|
||
"published_vrd_path": _agent_string_value(agent, "published_vrd_path") or None,
|
||
"published_extension_service_root": _agent_string_value(agent, "published_extension_service_root") or "sfera",
|
||
"published_extension_health_path": _agent_string_value(agent, "published_extension_health_path") or "health",
|
||
"sfera_extension_token": _agent_string_value(agent, "sfera_extension_token") or None,
|
||
}
|
||
job = AgentImportJob(
|
||
job_id=f"agent-import-{uuid4()}",
|
||
project_id=project_id,
|
||
agent_id=agent_id,
|
||
source=ImportSourceKind.LIVE_INFOBASE,
|
||
mode=ImportMode.FULL_REPLACE,
|
||
bin_path=_agent_string_value(agent, "one_c_bin") or None,
|
||
infobase=None,
|
||
metadata=metadata,
|
||
created_at=now,
|
||
updated_at=now,
|
||
logs=[
|
||
f"Queued SFERA extension install/update for Windows Agent {agent_id}.",
|
||
"Agent will run 1C Designer on its machine and will not upload an import archive for this service job.",
|
||
],
|
||
)
|
||
_agent_import_jobs[job.job_id] = job
|
||
return _persist_agent_import_job(job)
|
||
|
||
|
||
@app.delete("/projects/{project_id}", response_model=ProjectDeleteResponse)
|
||
async def delete_project(project_id: str, request: ProjectDeleteRequest) -> ProjectDeleteResponse:
|
||
if request.confirmation != project_id:
|
||
raise HTTPException(status_code=400, detail="Project deletion confirmation must match project_id.")
|
||
deleted = _delete_project_data(project_id, request)
|
||
return ProjectDeleteResponse(project_id=project_id, deleted=deleted)
|
||
|
||
|
||
@app.post("/projects/{project_id}/imports", response_model=ImportSummary)
|
||
async def import_project(project_id: str, request: ImportRequest) -> ImportSummary:
|
||
return _execute_import_project(project_id, request)
|
||
|
||
|
||
def _execute_import_project(
|
||
project_id: str,
|
||
request: ImportRequest,
|
||
progress: "ImportProgressCallback | None" = None,
|
||
) -> ImportSummary:
|
||
if request.mode == ImportMode.SYNC_PREVIEW:
|
||
return _import_project_sync_preview(project_id, request)
|
||
|
||
snapshot: SirSnapshot | None = None
|
||
normalized: NormalizedProject | None = None
|
||
errors: list[str] = []
|
||
_emit_import_progress(progress, "Проверка источника", stage="preflight")
|
||
source_path = _materialize_import_path(project_id, request, errors, progress=progress)
|
||
safe_metadata = _sanitize_import_metadata(request.metadata)
|
||
runtime_diagnostics: list[str] = []
|
||
runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock")
|
||
status = "mock_imported"
|
||
|
||
if _import_path_is_blocked(request, errors):
|
||
summary = _import_summary_from_snapshot(
|
||
project_id=project_id,
|
||
source=request.source,
|
||
status="import_blocked",
|
||
snapshot=None,
|
||
errors=errors,
|
||
metadata=safe_metadata,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=[],
|
||
normalized=None,
|
||
applied=False,
|
||
)
|
||
state = _project_setup.setdefault(project_id, {})
|
||
state["current_source"] = request.source.value
|
||
state["last_import"] = summary.model_dump(mode="json")
|
||
state["status"] = ProjectSetupStatus.IMPORT_REQUIRED.value
|
||
_append_import_history(project_id, summary)
|
||
_storage.write_document("project_settings", project_id, state)
|
||
return summary
|
||
|
||
_prepare_project_full_replace(project_id)
|
||
|
||
if (
|
||
request.run_indexing
|
||
and request.source in {ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE}
|
||
and source_path is not None
|
||
and source_path.exists()
|
||
and source_path.is_dir()
|
||
and _looks_like_metadata_dump(source_path)
|
||
):
|
||
_emit_import_progress(progress, "Индексация структуры из выгрузки CF/CFE", stage="indexing")
|
||
snapshot = _index_and_store(source_path, project_id, structure_only=True)
|
||
_emit_import_progress(progress, "Нормализация объектов 1С", stage="normalizing")
|
||
normalized = _normalize_and_store_import(project_id, source_path)
|
||
status = "structure_indexed"
|
||
elif request.run_indexing and _source_requires_runtime(request.source):
|
||
_emit_import_progress(progress, "Запрос runtime adapter", stage="runtime")
|
||
runtime_result = _call_runtime_adapter(project_id, request)
|
||
runtime_mode = str(runtime_result.get("mode", runtime_mode))
|
||
runtime_diagnostics.extend(str(item) for item in runtime_result.get("diagnostics", []))
|
||
runtime_diagnostics.extend(str(item) for item in runtime_result.get("dump_plan", []))
|
||
status = str(runtime_result.get("status", "mock_indexed"))
|
||
normalized = _normalized_from_runtime_result(project_id, runtime_result)
|
||
mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata)
|
||
_emit_import_progress(progress, "Индексация структуры", stage="indexing")
|
||
snapshot = _index_and_store(mock_root, project_id, structure_only=False)
|
||
normalized = normalized or _normalize_and_store_import(project_id, mock_root)
|
||
if status in {"mock_imported", "designer_dump_planned", "queued_for_remote_worker"}:
|
||
status = "mock_indexed" if status == "mock_imported" else status
|
||
elif source_path is not None and source_path.exists() and request.run_indexing:
|
||
_emit_import_progress(progress, "Индексация структуры", stage="indexing")
|
||
snapshot = _index_and_store(source_path, project_id, structure_only=request.structure_only)
|
||
_emit_import_progress(progress, "Нормализация объектов 1С", stage="normalizing")
|
||
normalized = _normalize_and_store_import(project_id, source_path)
|
||
status = "structure_indexed" if request.structure_only else "indexed"
|
||
elif request.run_indexing:
|
||
if source_path is not None and not source_path.exists():
|
||
errors.append(f"Path not found, mock import used: {request.path}")
|
||
mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata)
|
||
_emit_import_progress(progress, "Индексация mock-структуры", stage="indexing")
|
||
snapshot = _index_and_store(mock_root, project_id, structure_only=False)
|
||
normalized = normalized or _normalize_and_store_import(project_id, mock_root)
|
||
if status == "mock_imported":
|
||
status = "mock_indexed"
|
||
|
||
summary = _import_summary_from_snapshot(
|
||
project_id=project_id,
|
||
source=request.source,
|
||
status=status,
|
||
snapshot=snapshot,
|
||
errors=errors,
|
||
metadata=safe_metadata,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=runtime_diagnostics,
|
||
normalized=normalized,
|
||
)
|
||
state = _project_setup.setdefault(project_id, {})
|
||
state["current_source"] = request.source.value
|
||
state["last_import"] = summary.model_dump(mode="json")
|
||
_append_import_history(project_id, summary)
|
||
state["status"] = _setup_status_after_import(summary, request, snapshot).value
|
||
_storage.write_document("project_settings", project_id, state)
|
||
_emit_import_progress(progress, "Импорт завершен", stage="done")
|
||
return summary
|
||
|
||
|
||
@app.post("/projects/{project_id}/imports/{source}", response_model=ImportSummary)
|
||
async def import_project_from_source(project_id: str, source: ImportSourceKind, request: ImportRequest | None = None) -> ImportSummary:
|
||
payload = request or ImportRequest(source=source)
|
||
payload.source = source
|
||
return await import_project(project_id, payload)
|
||
|
||
|
||
@app.post("/projects/{project_id}/imports/{source}/jobs", response_model=OperationJob)
|
||
async def start_project_import_job(
|
||
project_id: str,
|
||
source: ImportSourceKind,
|
||
request: ImportRequest | None = None,
|
||
) -> OperationJob:
|
||
payload = request or ImportRequest(source=source)
|
||
payload.source = source
|
||
active_job = _active_server_import_job(project_id)
|
||
if active_job is not None:
|
||
return active_job
|
||
job = OperationJob(
|
||
job_id=f"server-import-{uuid4()}",
|
||
kind="SERVER_IMPORT",
|
||
status=OperationJobStatus.QUEUED,
|
||
payload={
|
||
"project_id": project_id,
|
||
"source": source.value,
|
||
"mode": payload.mode.value,
|
||
"path": payload.path,
|
||
"stage": "queued",
|
||
"message": "Импорт поставлен в очередь.",
|
||
"logs": ["Импорт поставлен в очередь."],
|
||
"started_at": None,
|
||
"finished_at": None,
|
||
"bytes_copied": 0,
|
||
"files_copied": 0,
|
||
},
|
||
)
|
||
_persist_job(_operations.enqueue(job))
|
||
threading.Thread(
|
||
target=_run_server_import_job,
|
||
args=(job.job_id, project_id, payload),
|
||
daemon=True,
|
||
).start()
|
||
return _operations.jobs[job.job_id]
|
||
|
||
|
||
@app.get("/operations/jobs/{job_id}", response_model=OperationJob)
|
||
async def get_job(job_id: str) -> OperationJob:
|
||
job = _operations.jobs.get(job_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}")
|
||
return job
|
||
|
||
|
||
@app.post("/projects/{project_id}/imports/{source}/check", response_model=ImportCheckResponse)
|
||
async def check_project_import(
|
||
project_id: str,
|
||
source: ImportSourceKind,
|
||
request: ImportRequest | None = None,
|
||
) -> ImportCheckResponse:
|
||
state = _project_setup.setdefault(project_id, {})
|
||
state["current_source"] = source.value
|
||
state.setdefault("status", ProjectSetupStatus.IMPORT_REQUIRED.value)
|
||
return _import_check_response(project_id, source, request or ImportRequest(source=source))
|
||
|
||
|
||
@app.post("/projects/{project_id}/reindex", response_model=ImportSummary)
|
||
async def reindex_project(project_id: str) -> ImportSummary:
|
||
state = _project_setup.get(project_id, {})
|
||
current_source = ImportSourceKind(state.get("current_source") or ImportSourceKind.XML_DUMP.value)
|
||
last_import = state.get("last_import") or {}
|
||
_prepare_project_full_replace(project_id)
|
||
source_path = Path(last_import.get("source_path", "")) if last_import.get("source_path") else None
|
||
if source_path is not None and source_path.exists():
|
||
snapshot = _index_and_store(source_path, project_id, structure_only=False)
|
||
normalized = _normalize_and_store_import(project_id, source_path)
|
||
else:
|
||
mock_root = _create_mock_import_fixture(project_id, current_source, {})
|
||
snapshot = _index_and_store(mock_root, project_id, structure_only=False)
|
||
normalized = _normalize_and_store_import(project_id, mock_root)
|
||
summary = _import_summary_from_snapshot(
|
||
project_id=project_id,
|
||
source=current_source,
|
||
status="reindexed",
|
||
snapshot=snapshot,
|
||
errors=[],
|
||
metadata={},
|
||
runtime_mode=os.environ.get("RUNTIME_ADAPTER_MODE", "mock"),
|
||
runtime_diagnostics=[],
|
||
normalized=normalized,
|
||
)
|
||
state = _project_setup.setdefault(project_id, {})
|
||
state["current_source"] = current_source.value
|
||
state["last_import"] = summary.model_dump(mode="json")
|
||
_append_import_history(project_id, summary)
|
||
state["status"] = ProjectSetupStatus.INDEXED.value
|
||
return summary
|
||
|
||
|
||
@app.get("/projects/{project_id}/imports/history", response_model=list[ImportSummary])
|
||
async def import_history(project_id: str) -> list[ImportSummary]:
|
||
state = _project_setup.get(project_id, {})
|
||
history = state.get("import_history")
|
||
if history is None:
|
||
try:
|
||
history = _storage.read_document("project_import_history", project_id).get("items", [])
|
||
except FileNotFoundError:
|
||
history = []
|
||
return [ImportSummary.model_validate(item) for item in history]
|
||
|
||
|
||
@app.post("/projects/{project_id}/imports/{source}/agent-jobs", response_model=AgentImportJob)
|
||
async def create_agent_import_job(
|
||
project_id: str,
|
||
source: ImportSourceKind,
|
||
request: AgentImportJobRequest,
|
||
) -> AgentImportJob:
|
||
if request.source != source:
|
||
raise HTTPException(status_code=400, detail="Request source must match path source.")
|
||
agent_id = request.agent_id.strip()
|
||
if not agent_id:
|
||
raise HTTPException(status_code=400, detail="agent_id is required.")
|
||
state = _project_setup.get(project_id)
|
||
if state:
|
||
settings = ProjectSettingsRequest.model_validate(state.get("settings", {}))
|
||
configured_agent_id = _agent_id_for_source(settings, source)
|
||
if configured_agent_id and configured_agent_id != agent_id:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"{source.value} is configured to run on Windows Agent {configured_agent_id}, but request uses {agent_id}. Save settings and select the configured agent.",
|
||
)
|
||
active_same_source = [
|
||
job
|
||
for job in _agent_import_jobs.values()
|
||
if job.project_id == project_id
|
||
and job.source == source
|
||
and job.status in {AgentImportJobStatus.QUEUED, AgentImportJobStatus.RUNNING}
|
||
]
|
||
if active_same_source:
|
||
active = max(active_same_source, key=lambda item: item.updated_at)
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"{source.value} import is already running for project {project_id} on Windows Agent {active.agent_id}. Wait for job {active.job_id} to finish before starting a new one.",
|
||
)
|
||
agent_status = _agent_status_with_liveness(_agent_statuses.get(agent_id, AgentStatus(agent_id=agent_id)))
|
||
if agent_status.status != "online":
|
||
raise HTTPException(status_code=409, detail=f"Windows Agent {agent_id} is offline. Select an online agent before import.")
|
||
if source in {
|
||
ImportSourceKind.XML_DUMP,
|
||
ImportSourceKind.EDT_PROJECT,
|
||
ImportSourceKind.ARCHIVE_DUMP,
|
||
ImportSourceKind.FILE_TREE,
|
||
} and not (request.local_path or "").strip():
|
||
raise HTTPException(status_code=400, detail="local_path is required for selected source.")
|
||
if source in {ImportSourceKind.CF_FILE, ImportSourceKind.CFE_FILE}:
|
||
has_agent_export_connection = bool(str(request.metadata.get("one_c_server") or "").strip()) and bool(
|
||
str(request.metadata.get("one_c_infobase") or "").strip()
|
||
)
|
||
if not (request.local_path or "").strip() and not has_agent_export_connection:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="local_path or 1C server/infobase settings are required for CF/CFE export.",
|
||
)
|
||
if source == ImportSourceKind.LIVE_INFOBASE and not (request.infobase or "").strip():
|
||
raise HTTPException(status_code=400, detail="infobase is required for LIVE_INFOBASE.")
|
||
|
||
from uuid import uuid4
|
||
|
||
now = _current_timestamp()
|
||
job = AgentImportJob(
|
||
job_id=f"agent-import-{uuid4()}",
|
||
project_id=project_id,
|
||
agent_id=agent_id,
|
||
source=source,
|
||
mode=request.mode,
|
||
local_path=request.local_path,
|
||
bin_path=request.bin_path,
|
||
infobase=request.infobase,
|
||
credentials_ref=request.credentials_ref,
|
||
metadata=request.metadata,
|
||
created_at=now,
|
||
updated_at=now,
|
||
logs=[
|
||
f"Queued {source.value} import for agent {agent_id}.",
|
||
"Windows agent resolves local_path on its own machine and returns server_path after export/upload.",
|
||
],
|
||
)
|
||
_agent_import_jobs[job.job_id] = job
|
||
return _persist_agent_import_job(job)
|
||
|
||
|
||
@app.get("/projects/{project_id}/imports/agent-jobs", response_model=list[AgentImportJob])
|
||
async def list_project_agent_import_jobs(project_id: str) -> list[AgentImportJob]:
|
||
return [
|
||
job
|
||
for job in sorted(_agent_import_jobs.values(), key=lambda item: item.created_at, reverse=True)
|
||
if job.project_id == project_id
|
||
]
|
||
|
||
|
||
@app.get("/agent/jobs/next", response_model=AgentImportJob | None)
|
||
async def claim_next_agent_import_job(agent_id: str, version: str | None = None) -> AgentImportJob | None:
|
||
current_version = _current_windows_agent_version()
|
||
if current_version and version is not None and version != current_version:
|
||
return None
|
||
for job in sorted(_agent_import_jobs.values(), key=lambda item: item.created_at):
|
||
if job.agent_id != agent_id or job.status != AgentImportJobStatus.QUEUED:
|
||
continue
|
||
now = _current_timestamp()
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.claimed_at = now
|
||
job.updated_at = now
|
||
job.logs.append(f"Claimed by agent {agent_id}.")
|
||
return _persist_agent_import_job(job)
|
||
return None
|
||
|
||
|
||
@app.post("/agent/browse-requests", response_model=AgentBrowseRequest)
|
||
async def create_agent_browse_request(request: AgentBrowseRequestCreate) -> AgentBrowseRequest:
|
||
agent_id = request.agent_id.strip()
|
||
if not agent_id:
|
||
raise HTTPException(status_code=400, detail="agent_id is required.")
|
||
|
||
from uuid import uuid4
|
||
|
||
now = _current_timestamp()
|
||
browse_request = AgentBrowseRequest(
|
||
request_id=f"agent-browse-{uuid4()}",
|
||
agent_id=agent_id,
|
||
path=request.path,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
_agent_browse_requests[browse_request.request_id] = browse_request
|
||
return _persist_agent_browse_request(browse_request)
|
||
|
||
|
||
@app.get("/agent/browse-requests/{request_id}", response_model=AgentBrowseRequest)
|
||
async def get_agent_browse_request(request_id: str) -> AgentBrowseRequest:
|
||
browse_request = _agent_browse_requests.get(request_id)
|
||
if browse_request is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown agent browse request: {request_id}")
|
||
return browse_request
|
||
|
||
|
||
@app.get("/agent/browse/next", response_model=AgentBrowseRequest | None)
|
||
async def claim_next_agent_browse_request(agent_id: str) -> AgentBrowseRequest | None:
|
||
for browse_request in sorted(_agent_browse_requests.values(), key=lambda item: item.created_at):
|
||
if browse_request.agent_id != agent_id or browse_request.status != AgentBrowseRequestStatus.QUEUED:
|
||
continue
|
||
now = _current_timestamp()
|
||
browse_request.status = AgentBrowseRequestStatus.RUNNING
|
||
browse_request.claimed_at = now
|
||
browse_request.updated_at = now
|
||
return _persist_agent_browse_request(browse_request)
|
||
return None
|
||
|
||
|
||
@app.post("/agent/browse/{request_id}/result", response_model=AgentBrowseRequest)
|
||
async def complete_agent_browse_request(request_id: str, request: AgentBrowseResult) -> AgentBrowseRequest:
|
||
browse_request = _agent_browse_requests.get(request_id)
|
||
if browse_request is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown agent browse request: {request_id}")
|
||
browse_request.status = request.status
|
||
browse_request.entries = request.entries
|
||
browse_request.parent_path = request.parent_path
|
||
browse_request.error = request.error
|
||
browse_request.completed_at = _current_timestamp()
|
||
browse_request.updated_at = browse_request.completed_at
|
||
return _persist_agent_browse_request(browse_request)
|
||
|
||
|
||
@app.post("/agent/heartbeat", response_model=AgentStatus)
|
||
async def register_agent_heartbeat(request: AgentHeartbeatRequest) -> AgentStatus:
|
||
agent_id = request.agent_id.strip()
|
||
if not agent_id:
|
||
raise HTTPException(status_code=400, detail="agent_id is required.")
|
||
status = AgentStatus(
|
||
agent_id=agent_id,
|
||
status="online",
|
||
last_seen_at=_current_timestamp(),
|
||
host=request.host,
|
||
user=request.user,
|
||
version=request.version,
|
||
started_at=request.started_at,
|
||
network_roots=request.network_roots,
|
||
platform_bins=request.platform_bins,
|
||
)
|
||
return _persist_agent_status(status)
|
||
|
||
|
||
@app.get("/agent/config", response_model=AgentConfigResponse)
|
||
async def get_agent_config(agent_id: str) -> AgentConfigResponse:
|
||
normalized_agent_id = agent_id.strip()
|
||
if not normalized_agent_id:
|
||
raise HTTPException(status_code=400, detail="agent_id is required.")
|
||
projects: list[AgentProjectConfig] = []
|
||
for project_id, state in sorted(_project_setup.items()):
|
||
settings = ProjectSettingsRequest.model_validate(state.get("settings", {}))
|
||
agent = settings.agent if isinstance(settings.agent, dict) else {}
|
||
configured_agent_ids = {
|
||
str(agent.get("agent_id") or "").strip(),
|
||
str(agent.get("cf_agent_id") or "").strip(),
|
||
str(agent.get("edt_agent_id") or "").strip(),
|
||
}
|
||
if normalized_agent_id not in configured_agent_ids:
|
||
continue
|
||
server_import = settings.server_import if isinstance(settings.server_import, dict) else {}
|
||
projects.append(
|
||
AgentProjectConfig(
|
||
project_id=project_id,
|
||
project_name=settings.name or project_id,
|
||
one_c_bin=str(agent.get("one_c_bin") or "") or None,
|
||
edt_path=str(agent.get("edt_path") or "") or None,
|
||
default_local_path=str(server_import.get("path") or "") or None,
|
||
network_roots=[str(item) for item in agent.get("network_roots", []) if str(item).strip()]
|
||
if isinstance(agent.get("network_roots"), list)
|
||
else [],
|
||
)
|
||
)
|
||
return AgentConfigResponse(agent_id=normalized_agent_id, projects=projects)
|
||
|
||
|
||
@app.get("/agent/windows/manifest")
|
||
async def windows_agent_manifest(request: Request) -> dict:
|
||
script = _windows_agent_script_path()
|
||
public_api_origin = _public_api_origin(request)
|
||
agent_version = _current_windows_agent_version() or _file_sha256(script)[:12]
|
||
return {
|
||
"version": agent_version,
|
||
"script_hash": _file_sha256(script)[:12],
|
||
"script_url": f"{public_api_origin}/agent/windows/sfera-windows-agent.ps1",
|
||
"api_url": public_api_origin,
|
||
"install_url": f"{_public_origin(request)}/api/sfera/agent/windows/install.ps1",
|
||
"install_cmd_url": f"{_public_origin(request)}/api/sfera/agent/windows/install.cmd",
|
||
}
|
||
|
||
|
||
@app.get("/agent/windows/sfera-windows-agent.ps1", response_class=PlainTextResponse)
|
||
async def download_windows_agent_script() -> PlainTextResponse:
|
||
return PlainTextResponse(
|
||
_windows_agent_script_path().read_text(encoding="utf-8"),
|
||
headers={"Content-Disposition": 'attachment; filename="sfera-windows-agent.ps1"'},
|
||
)
|
||
|
||
|
||
def _current_windows_agent_version() -> str | None:
|
||
script = _windows_agent_script_path()
|
||
script_text = script.read_text(encoding="utf-8")
|
||
version_match = re.search(r'^\s*\$AgentVersion\s*=\s*"([^"]+)"', script_text, re.MULTILINE)
|
||
return version_match.group(1) if version_match else None
|
||
|
||
|
||
@app.get("/agent/windows/install.ps1", response_class=PlainTextResponse)
|
||
async def download_windows_agent_installer(request: Request, agent_id: str | None = None) -> PlainTextResponse:
|
||
public_origin = _public_origin(request)
|
||
public_api_origin = _public_api_origin(request)
|
||
default_agent_id = agent_id or "sfera-$env:COMPUTERNAME"
|
||
script = f"""param(
|
||
[string]$AgentId = "{default_agent_id}",
|
||
[string]$InstallDir = "C:\\ProgramData\\SFERA\\WindowsAgent",
|
||
[string]$TaskName = "SferaWindowsAgent",
|
||
[string]$WatchdogTaskName = "SferaWindowsAgentWatchdog",
|
||
[string]$ServiceName = "SferaWindowsAgent"
|
||
)
|
||
|
||
$ErrorActionPreference = "Stop"
|
||
$ServerUrl = "{public_origin}"
|
||
$ApiUrl = "{public_api_origin}"
|
||
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {{
|
||
throw "Run this installer in PowerShell as Administrator."
|
||
}}
|
||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||
$logPath = Join-Path $InstallDir "install.log"
|
||
Start-Transcript -Path $logPath -Append | Out-Null
|
||
try {{
|
||
$agentScript = Join-Path $InstallDir "sfera-windows-agent.ps1"
|
||
$watchdogScript = Join-Path $InstallDir "sfera-windows-agent-watchdog.ps1"
|
||
$configPath = Join-Path $InstallDir "agent-config.json"
|
||
Invoke-WebRequest -UseBasicParsing -Uri "$ApiUrl/agent/windows/sfera-windows-agent.ps1" -OutFile $agentScript
|
||
$config = [ordered]@{{ server_url = $ServerUrl; api_url = $ApiUrl; agent_id = $AgentId; poll_seconds = 5; network_roots = @() }}
|
||
$config | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $configPath -Encoding UTF8
|
||
$arguments = @("-STA", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$agentScript`"", "-ConfigPath", "`"$configPath`"")
|
||
$powerShellPath = Join-Path $env:SystemRoot "System32\\WindowsPowerShell\\v1.0\\powershell.exe"
|
||
if (!(Test-Path -LiteralPath $powerShellPath -PathType Leaf)) {{
|
||
$powerShellPath = Join-Path $PSHOME "powershell.exe"
|
||
}}
|
||
|
||
$watchdogContent = @"
|
||
`$ErrorActionPreference = "SilentlyContinue"
|
||
`$agentScript = "$agentScript"
|
||
`$configPath = "$configPath"
|
||
`$powerShellPath = "$powerShellPath"
|
||
`$agentArgs = @("-STA", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"`$agentScript`"", "-ConfigPath", "`"`$configPath`"")
|
||
`$running = Get-CimInstance Win32_Process | Where-Object {{
|
||
`$_.CommandLine -and `$_.CommandLine.Contains("sfera-windows-agent.ps1") -and `$_.CommandLine.Contains(`$configPath)
|
||
}}
|
||
if (-not `$running) {{
|
||
Start-Process -FilePath `$powerShellPath -ArgumentList `$agentArgs -WindowStyle Hidden
|
||
Add-Content -LiteralPath "$logPath" -Value ("[{{0}}] Watchdog started SFERA Windows Agent." -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"))
|
||
}}
|
||
"@
|
||
$watchdogContent | Set-Content -LiteralPath $watchdogScript -Encoding UTF8
|
||
|
||
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||
if ($existingTask) {{
|
||
Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||
}}
|
||
|
||
$existingWatchdogTask = Get-ScheduledTask -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue
|
||
if ($existingWatchdogTask) {{
|
||
Stop-ScheduledTask -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue
|
||
Unregister-ScheduledTask -TaskName $WatchdogTaskName -Confirm:$false
|
||
}}
|
||
|
||
$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||
if ($existing) {{
|
||
sc.exe stop $ServiceName | Out-Null
|
||
sc.exe delete $ServiceName | Out-Null
|
||
Start-Sleep -Seconds 2
|
||
}}
|
||
|
||
$action = New-ScheduledTaskAction -Execute $powerShellPath -Argument ($arguments -join " ")
|
||
$trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"
|
||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
|
||
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Highest
|
||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description "SFERA bridge between local 1C/EDT/network folders and SFERA server." -Force | Out-Null
|
||
|
||
$watchdogArguments = @("-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", "`"$watchdogScript`"")
|
||
$watchdogAction = New-ScheduledTaskAction -Execute $powerShellPath -Argument ($watchdogArguments -join " ")
|
||
$watchdogLogonTrigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"
|
||
$watchdogSettings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -MultipleInstances IgnoreNew
|
||
Register-ScheduledTask -TaskName $WatchdogTaskName -Action $watchdogAction -Trigger $watchdogLogonTrigger -Settings $watchdogSettings -Principal $principal -Description "Keeps SFERA Windows Agent running after reboot or crash." -Force | Out-Null
|
||
$watchdogTaskCommand = "`"$powerShellPath`" $($watchdogArguments -join " ")"
|
||
schtasks.exe /Create /TN $WatchdogTaskName /TR $watchdogTaskCommand /SC MINUTE /MO 1 /F /RL HIGHEST | Out-Null
|
||
|
||
Start-ScheduledTask -TaskName $TaskName
|
||
Start-ScheduledTask -TaskName $WatchdogTaskName
|
||
Start-Sleep -Seconds 5
|
||
$agentTaskInfo = Get-ScheduledTaskInfo -TaskName $TaskName -ErrorAction SilentlyContinue
|
||
$watchdogTaskInfo = Get-ScheduledTaskInfo -TaskName $WatchdogTaskName -ErrorAction SilentlyContinue
|
||
$agentProcess = Get-CimInstance Win32_Process | Where-Object {{ $_.CommandLine -and $_.CommandLine.Contains("sfera-windows-agent.ps1") -and $_.CommandLine.Contains($configPath) }} | Select-Object -First 1
|
||
if ($agentTaskInfo) {{
|
||
Write-Host "AgentTaskResult=$($agentTaskInfo.LastTaskResult)"
|
||
}}
|
||
if ($watchdogTaskInfo) {{
|
||
Write-Host "WatchdogTaskResult=$($watchdogTaskInfo.LastTaskResult)"
|
||
}}
|
||
if ($agentProcess) {{
|
||
Write-Host "AgentProcessId=$($agentProcess.ProcessId)"
|
||
}} else {{
|
||
Write-Warning "Agent process is not running after install. Check $logPath and Windows Task Scheduler history."
|
||
}}
|
||
Write-Host "SFERA Windows Agent installed as Windows Scheduled Task."
|
||
Write-Host "ServerUrl=$ServerUrl"
|
||
Write-Host "ApiUrl=$ApiUrl"
|
||
Write-Host "AgentId=$AgentId"
|
||
Write-Host "Config=$configPath"
|
||
Write-Host "TaskName=$TaskName"
|
||
Write-Host "WatchdogTaskName=$WatchdogTaskName"
|
||
Write-Host "Log=$logPath"
|
||
Write-Host "Open SFERA and wait 5-10 seconds for agent heartbeat."
|
||
}} catch {{
|
||
Write-Error $_
|
||
throw
|
||
}} finally {{
|
||
Stop-Transcript | Out-Null
|
||
}}
|
||
"""
|
||
return PlainTextResponse(
|
||
script,
|
||
headers={"Content-Disposition": 'attachment; filename="install-sfera-windows-agent.ps1"'},
|
||
)
|
||
|
||
|
||
@app.get("/agent/windows/install.cmd", response_class=PlainTextResponse)
|
||
async def download_windows_agent_cmd_installer(request: Request, agent_id: str | None = None) -> PlainTextResponse:
|
||
public_origin = _public_origin(request)
|
||
installer_url = f"{public_origin}/api/sfera/agent/windows/install.ps1"
|
||
if agent_id:
|
||
installer_url = f"{installer_url}?agent_id={quote(agent_id, safe='')}"
|
||
escaped_installer_url = installer_url.replace("%", "%%")
|
||
script = fr"""@echo off
|
||
setlocal
|
||
set "INSTALLER_URL={escaped_installer_url}"
|
||
set "INSTALLER=%TEMP%\install-sfera-windows-agent.ps1"
|
||
echo Downloading SFERA Windows Agent installer...
|
||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing -Uri $env:INSTALLER_URL -OutFile $env:INSTALLER"
|
||
if errorlevel 1 (
|
||
echo Failed to download installer.
|
||
pause
|
||
exit /b 1
|
||
)
|
||
echo Starting installer with administrator rights...
|
||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath powershell.exe -Verb RunAs -Wait -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$env:INSTALLER)"
|
||
if errorlevel 1 (
|
||
echo Failed to start elevated installer.
|
||
pause
|
||
exit /b 1
|
||
)
|
||
echo Installer finished.
|
||
echo.
|
||
echo Installed files and last log:
|
||
powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "if (Test-Path 'C:\ProgramData\SFERA\WindowsAgent') {{ Get-ScheduledTask -TaskName SferaWindowsAgent,SferaWindowsAgentWatchdog -ErrorAction SilentlyContinue | Select-Object TaskName,State | Format-Table -AutoSize; Get-ScheduledTaskInfo -TaskName SferaWindowsAgent -ErrorAction SilentlyContinue | Select-Object LastRunTime,LastTaskResult | Format-List; Get-Content 'C:\ProgramData\SFERA\WindowsAgent\install.log' -Tail 40 -ErrorAction SilentlyContinue }}"
|
||
echo.
|
||
pause
|
||
"""
|
||
return PlainTextResponse(
|
||
script,
|
||
media_type="application/octet-stream",
|
||
headers={"Content-Disposition": 'attachment; filename="install-sfera-windows-agent.cmd"'},
|
||
)
|
||
|
||
|
||
@app.get("/agent/statuses", response_model=list[AgentStatus])
|
||
async def list_agent_statuses() -> list[AgentStatus]:
|
||
statuses = [_agent_status_with_liveness(status.model_copy(deep=True)) for status in _agent_statuses.values()]
|
||
return sorted(statuses, key=lambda status: status.last_seen_at or "", reverse=True)
|
||
|
||
|
||
@app.get("/agent/status/{agent_id}", response_model=AgentStatus)
|
||
async def get_agent_status(agent_id: str) -> AgentStatus:
|
||
status = _agent_statuses.get(agent_id)
|
||
if status is None:
|
||
return AgentStatus(agent_id=agent_id, status="offline")
|
||
return _agent_status_with_liveness(status)
|
||
|
||
|
||
@app.get("/server/browse", response_model=ServerBrowseResponse)
|
||
async def browse_server_folders(path: str | None = None) -> ServerBrowseResponse:
|
||
target = Path(path or os.environ.get("SFERA_SERVER_BROWSE_ROOT", "/"))
|
||
try:
|
||
resolved = target.expanduser().resolve()
|
||
if not resolved.exists():
|
||
return ServerBrowseResponse(path=str(target), error=f"Папка не найдена: {target}")
|
||
if not resolved.is_dir():
|
||
return ServerBrowseResponse(path=str(resolved), error=f"Это не папка: {resolved}")
|
||
entries = [
|
||
AgentFolderEntry(name=item.name, path=item.as_posix(), is_directory=True)
|
||
for item in sorted(resolved.iterdir(), key=lambda item: item.name.lower())
|
||
if item.is_dir()
|
||
][:200]
|
||
parent = resolved.parent.as_posix() if resolved.parent != resolved else None
|
||
return ServerBrowseResponse(path=resolved.as_posix(), parent_path=parent, entries=entries)
|
||
except PermissionError:
|
||
return ServerBrowseResponse(path=str(target), error=f"Нет доступа к папке: {target}")
|
||
except OSError as error:
|
||
return ServerBrowseResponse(path=str(target), error=str(error))
|
||
|
||
|
||
@app.post("/server/smb/browse", response_model=ServerBrowseResponse)
|
||
async def browse_server_smb_folders(request: ServerSmbBrowseRequest) -> ServerBrowseResponse:
|
||
path = request.path.strip()
|
||
if not path:
|
||
return ServerBrowseResponse(path="", error="Укажите UNC путь к сетевой папке.")
|
||
if not path.startswith("\\\\"):
|
||
return ServerBrowseResponse(path=path, error="SMB-доступ ожидает UNC путь вида \\\\server\\share\\folder.")
|
||
try:
|
||
return _browse_smb_folders(
|
||
path=path,
|
||
username=(request.username or "").strip() or None,
|
||
password=request.password or None,
|
||
domain=(request.domain or "").strip() or None,
|
||
)
|
||
except Exception as error:
|
||
return ServerBrowseResponse(path=path, error=f"SMB доступ не выполнен: {error}")
|
||
|
||
|
||
@app.post("/agent/jobs/{job_id}/logs", response_model=AgentImportJob)
|
||
async def append_agent_import_job_logs(job_id: str, request: AgentImportJobLogRequest) -> AgentImportJob:
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}")
|
||
_touch_agent_activity(job.agent_id)
|
||
if request.status is not None:
|
||
job.status = request.status
|
||
job.logs.extend(request.logs)
|
||
job.updated_at = _current_timestamp()
|
||
return _persist_agent_import_job(job)
|
||
|
||
|
||
@app.post("/agent/jobs/{job_id}/upload", response_model=AgentImportJob)
|
||
async def upload_agent_import_job_payload(job_id: str, request: Request, filename: str = "payload.zip") -> AgentImportJob:
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}")
|
||
_touch_agent_activity(job.agent_id)
|
||
upload_root = _storage.root / "agent_uploads" / _safe_storage_name(job_id)
|
||
import_root = _storage.root / "agent_imports" / _safe_storage_name(job_id)
|
||
upload_root.mkdir(parents=True, exist_ok=True)
|
||
if import_root.exists():
|
||
shutil.rmtree(import_root)
|
||
import_root.mkdir(parents=True, exist_ok=True)
|
||
upload_path = upload_root / _safe_storage_name(filename)
|
||
size = 0
|
||
last_touch = time.monotonic()
|
||
with upload_path.open("wb") as file:
|
||
async for chunk in request.stream():
|
||
if not chunk:
|
||
continue
|
||
size += len(chunk)
|
||
file.write(chunk)
|
||
if time.monotonic() - last_touch >= 30:
|
||
_touch_agent_activity(job.agent_id)
|
||
last_touch = time.monotonic()
|
||
if size == 0:
|
||
try:
|
||
upload_path.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
raise HTTPException(status_code=400, detail="Upload payload is empty.")
|
||
if zipfile.is_zipfile(upload_path):
|
||
job.server_path = import_root.as_posix()
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.error = None
|
||
job.metadata = {
|
||
**job.metadata,
|
||
"upload_path": upload_path.as_posix(),
|
||
"upload_size": size,
|
||
"upload_processing": True,
|
||
}
|
||
job.logs.append(f"Uploaded {filename} ({size} bytes). Server extraction queued.")
|
||
job.updated_at = _current_timestamp()
|
||
_persist_agent_import_job(job)
|
||
threading.Thread(
|
||
target=_run_agent_uploaded_zip_job,
|
||
args=(job.job_id, upload_path.as_posix(), import_root.as_posix(), filename, size),
|
||
daemon=True,
|
||
).start()
|
||
else:
|
||
target_path = import_root / upload_path.name
|
||
shutil.copyfile(upload_path, target_path)
|
||
job.server_path = target_path.as_posix()
|
||
job.logs.append(f"Uploaded {filename} ({size} bytes) to {job.server_path}.")
|
||
job.updated_at = _current_timestamp()
|
||
_touch_agent_activity(job.agent_id)
|
||
return _persist_agent_import_job(job)
|
||
|
||
|
||
@app.post("/agent/jobs/{job_id}/result", response_model=AgentImportJob)
|
||
async def complete_agent_import_job(job_id: str, request: AgentImportJobResult) -> AgentImportJob:
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown agent import job: {job_id}")
|
||
_touch_agent_activity(job.agent_id)
|
||
job.metadata = {**job.metadata, **request.metadata}
|
||
if job.metadata.get("agent_action") == "INSTALL_SFERA_EXTENSION":
|
||
job.status = request.status
|
||
job.error = request.error
|
||
job.logs.extend(request.logs)
|
||
job.updated_at = _current_timestamp()
|
||
if request.status in {AgentImportJobStatus.SUCCEEDED, AgentImportJobStatus.FAILED, AgentImportJobStatus.CANCELLED}:
|
||
job.completed_at = job.updated_at
|
||
if request.status == AgentImportJobStatus.SUCCEEDED:
|
||
job.logs.append("SFERA extension install/update service job completed.")
|
||
return _persist_agent_import_job(job)
|
||
|
||
if (
|
||
request.status == AgentImportJobStatus.FAILED
|
||
and job.import_summary is None
|
||
and str(request.error or "").strip()
|
||
):
|
||
timeout_markers = ("Время ожидания операции истекло", "timed out", "timeout")
|
||
if any(marker.lower() in str(request.error).lower() for marker in timeout_markers) and (
|
||
job.server_path or job.metadata.get("upload_processing") or job.metadata.get("upload_path")
|
||
):
|
||
job.logs.extend(request.logs)
|
||
job.logs.append("Agent reported timeout after upload; server import will continue in background.")
|
||
job.error = None
|
||
job.updated_at = _current_timestamp()
|
||
upload_path_raw = str(job.metadata.get("upload_path") or "")
|
||
if upload_path_raw and not job.server_path:
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.completed_at = None
|
||
job.metadata = {**job.metadata, "upload_processing": True}
|
||
_persist_agent_import_job(job)
|
||
upload_path = Path(upload_path_raw)
|
||
import_root = _storage.root / "agent_imports" / _safe_storage_name(job.job_id)
|
||
upload_size = int(job.metadata.get("upload_size") or 0)
|
||
threading.Thread(
|
||
target=_run_agent_uploaded_zip_job,
|
||
args=(job.job_id, upload_path.as_posix(), import_root.as_posix(), upload_path.name, upload_size),
|
||
daemon=True,
|
||
).start()
|
||
return job
|
||
if job.metadata.get("upload_processing"):
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.completed_at = None
|
||
return _persist_agent_import_job(job)
|
||
import_request = ImportRequest(
|
||
source=job.source,
|
||
path=job.server_path,
|
||
credentials_ref=job.credentials_ref,
|
||
metadata={
|
||
**job.metadata,
|
||
"agent_id": job.agent_id,
|
||
"agent_local_path": job.local_path,
|
||
"agent_bin_path": job.bin_path,
|
||
"agent_infobase": job.infobase,
|
||
},
|
||
structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP},
|
||
mode=job.mode,
|
||
)
|
||
operation_job = _create_agent_server_import_job(job, import_request)
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.completed_at = None
|
||
job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id}
|
||
job.logs.append(f"Server import queued as {operation_job.job_id}.")
|
||
_persist_agent_import_job(job)
|
||
threading.Thread(
|
||
target=_run_agent_server_import_job,
|
||
args=(job.job_id, operation_job.job_id, job.project_id, import_request),
|
||
daemon=True,
|
||
).start()
|
||
return job
|
||
|
||
if request.status == AgentImportJobStatus.SUCCEEDED and job.metadata.get("upload_processing"):
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.error = None
|
||
job.logs.extend(request.logs)
|
||
job.logs.append("Agent upload completed; server extraction/import is running in background.")
|
||
job.updated_at = _current_timestamp()
|
||
job.completed_at = None
|
||
return _persist_agent_import_job(job)
|
||
|
||
job.status = request.status
|
||
job.server_path = request.server_path or job.server_path
|
||
job.error = request.error
|
||
job.logs.extend(request.logs)
|
||
job.updated_at = _current_timestamp()
|
||
|
||
if request.status == AgentImportJobStatus.SUCCEEDED:
|
||
if not job.server_path:
|
||
job.status = AgentImportJobStatus.FAILED
|
||
job.error = "Agent result requires server_path or prior upload."
|
||
job.completed_at = job.updated_at
|
||
else:
|
||
import_request = ImportRequest(
|
||
source=job.source,
|
||
path=job.server_path,
|
||
credentials_ref=job.credentials_ref,
|
||
metadata={
|
||
**job.metadata,
|
||
"agent_id": job.agent_id,
|
||
"agent_local_path": job.local_path,
|
||
"agent_bin_path": job.bin_path,
|
||
"agent_infobase": job.infobase,
|
||
},
|
||
structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP},
|
||
mode=job.mode,
|
||
)
|
||
operation_job = _create_agent_server_import_job(job, import_request)
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.error = None
|
||
job.completed_at = None
|
||
job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id}
|
||
job.logs.append(f"Server import queued as {operation_job.job_id}.")
|
||
_persist_agent_import_job(job)
|
||
threading.Thread(
|
||
target=_run_agent_server_import_job,
|
||
args=(job.job_id, operation_job.job_id, job.project_id, import_request),
|
||
daemon=True,
|
||
).start()
|
||
return job
|
||
else:
|
||
job.completed_at = job.updated_at
|
||
return _persist_agent_import_job(job)
|
||
|
||
|
||
@app.get("/projects/{project_id}/normalized", response_model=NormalizedProject)
|
||
async def get_normalized_project(project_id: str) -> NormalizedProject:
|
||
normalized = _load_normalized_project(project_id)
|
||
if normalized is None:
|
||
raise HTTPException(status_code=404, detail="NormalizedProject not found")
|
||
return normalized
|
||
|
||
|
||
@app.get("/projects/{project_id}/normalized/summary", response_model=NormalizedProjectSummary)
|
||
async def get_normalized_project_summary(project_id: str) -> NormalizedProjectSummary:
|
||
normalized = _load_normalized_project(project_id)
|
||
if normalized is None:
|
||
raise HTTPException(status_code=404, detail="NormalizedProject not found")
|
||
return _normalized_project_summary(normalized)
|
||
|
||
|
||
@app.get("/projects/{project_id}/imports/quality", response_model=ImportQualityResponse)
|
||
async def get_import_quality(project_id: str) -> ImportQualityResponse:
|
||
return _import_quality_response(project_id)
|
||
|
||
|
||
@app.get("/projects/{project_id}/normalized/object", response_model=NormalizedObjectDetail)
|
||
async def get_normalized_object(project_id: str, qualified_name: str) -> NormalizedObjectDetail:
|
||
normalized = _load_normalized_project(project_id)
|
||
if normalized is None:
|
||
raise HTTPException(status_code=404, detail="NormalizedProject not found")
|
||
detail = _normalized_object_detail(normalized, qualified_name)
|
||
if detail is None:
|
||
raise HTTPException(status_code=404, detail=f"Normalized object not found: {qualified_name}")
|
||
return detail
|
||
|
||
|
||
@app.get("/projects/{project_id}/normalized/object/modules", response_model=list[ModuleSourceResponse])
|
||
async def get_normalized_object_modules(project_id: str, qualified_name: str) -> list[ModuleSourceResponse]:
|
||
normalized = _load_normalized_project(project_id)
|
||
if normalized is not None:
|
||
modules = _normalized_module_sources_for_object(normalized, qualified_name)
|
||
if modules:
|
||
return modules
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return _module_sources_for_object(snapshot, qualified_name)
|
||
|
||
|
||
@app.get("/projects/{project_id}/bsl/completions", response_model=list[BslCompletionItemResponse])
|
||
async def get_bsl_completions(
|
||
project_id: str,
|
||
receiver: str | None = None,
|
||
q: str = "",
|
||
qualified_name: str | None = None,
|
||
limit: int = 80,
|
||
) -> list[BslCompletionItemResponse]:
|
||
normalized = _load_normalized_project(project_id)
|
||
normalized_limit = min(max(1, limit), 250)
|
||
suggestions: list[BslCompletionItemResponse] = []
|
||
if normalized is not None:
|
||
suggestions.extend(_normalized_bsl_completion_items(normalized, receiver, qualified_name))
|
||
if not suggestions:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
suggestions.extend(_snapshot_bsl_completion_items(snapshot, receiver))
|
||
normalized_query = q.strip().casefold()
|
||
if normalized_query:
|
||
suggestions = [item for item in suggestions if normalized_query in item.label.casefold()]
|
||
unique: dict[str, BslCompletionItemResponse] = {}
|
||
for item in suggestions:
|
||
key = f"{item.kind}:{item.label}".casefold()
|
||
unique.setdefault(key, item)
|
||
return sorted(unique.values(), key=lambda item: (item.kind, item.label.casefold()))[:normalized_limit]
|
||
|
||
|
||
@app.get("/metadata/catalog", response_model=MetadataCatalogResponse)
|
||
async def metadata_catalog() -> MetadataCatalogResponse:
|
||
return MetadataCatalogResponse(
|
||
platform_family="1C:Enterprise 8.3 baseline, 8.5-compatible catalog shell",
|
||
source="docs/1c-metadata-structure.md",
|
||
common_branch_children=list(COMMON_BRANCH_CHILDREN),
|
||
types=[
|
||
MetadataTypeSpecResponse(
|
||
code=spec.code,
|
||
russian_name=spec.russian_name,
|
||
tree_branch=spec.tree_branch,
|
||
icon=spec.icon,
|
||
description=METADATA_TYPE_DESCRIPTIONS.get(spec.code, ""),
|
||
documentation_url=METADATA_TYPE_DOCUMENTATION_URLS.get(spec.code, ""),
|
||
child_groups=list(spec.child_groups),
|
||
module_kinds=list(spec.module_kinds),
|
||
properties=list(METADATA_TYPE_PROPERTIES.get(spec.code, spec.properties)),
|
||
context_actions=list(METADATA_TYPE_CONTEXT_ACTIONS.get(spec.code, spec.context_actions)),
|
||
)
|
||
for spec in METADATA_TYPE_SPECS
|
||
],
|
||
child_object_types=[
|
||
MetadataChildObjectSpecResponse(
|
||
code=spec.code,
|
||
russian_name=spec.russian_name,
|
||
parent_groups=list(spec.parent_groups),
|
||
description=spec.description,
|
||
documentation_url=spec.documentation_url,
|
||
)
|
||
for spec in METADATA_CHILD_OBJECT_SPECS
|
||
],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/metadata/tree", response_model=ProjectMetadataTreeResponse)
|
||
async def project_metadata_tree(project_id: str, object_limit_per_branch: int = 200) -> ProjectMetadataTreeResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
normalized = _load_normalized_project(project_id)
|
||
root = (
|
||
_project_metadata_tree_response_from_normalized(normalized, object_limit_per_branch=max(0, object_limit_per_branch))
|
||
if normalized is not None
|
||
else _project_metadata_tree_response(snapshot, object_limit_per_branch=max(0, object_limit_per_branch))
|
||
)
|
||
return ProjectMetadataTreeResponse(
|
||
project_id=project_id,
|
||
root=root,
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/metadata/tree/children", response_model=MetadataTreeChildrenResponse)
|
||
async def project_metadata_tree_children(
|
||
project_id: str,
|
||
node_id: str,
|
||
offset: int = 0,
|
||
limit: int = 50,
|
||
) -> MetadataTreeChildrenResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
normalized = _load_normalized_project(project_id)
|
||
normalized_offset = max(0, offset)
|
||
normalized_limit = min(max(1, limit), 250)
|
||
normalized_children = (
|
||
_normalized_metadata_tree_children_for_node(
|
||
normalized,
|
||
node_id=node_id,
|
||
offset=normalized_offset,
|
||
limit=normalized_limit,
|
||
)
|
||
if normalized is not None
|
||
else None
|
||
)
|
||
if normalized_children is None:
|
||
children, total = _metadata_tree_children_for_node(
|
||
snapshot,
|
||
node_id=node_id,
|
||
offset=normalized_offset,
|
||
limit=normalized_limit,
|
||
)
|
||
else:
|
||
children, total = normalized_children
|
||
return MetadataTreeChildrenResponse(
|
||
project_id=project_id,
|
||
parent_id=node_id,
|
||
offset=normalized_offset,
|
||
limit=normalized_limit,
|
||
total=total,
|
||
has_more=normalized_offset + len(children) < total,
|
||
children=children,
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/metadata/tree/search", response_model=MetadataTreeSearchResponse)
|
||
async def project_metadata_tree_search(project_id: str, q: str, limit: int = 80) -> MetadataTreeSearchResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
normalized_query = q.strip().casefold()
|
||
normalized_limit = min(max(1, limit), 250)
|
||
if len(normalized_query) < 2:
|
||
return MetadataTreeSearchResponse(project_id=project_id, q=q, total=0, results=[])
|
||
matches = [
|
||
node
|
||
for node in snapshot.nodes
|
||
if _is_metadata_tree_search_node(node)
|
||
and (
|
||
normalized_query in node.name.casefold()
|
||
or normalized_query in node.qualified_name.casefold()
|
||
)
|
||
]
|
||
matches.sort(key=lambda item: _metadata_search_rank(item, normalized_query))
|
||
page = matches[:normalized_limit]
|
||
child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in page])
|
||
return MetadataTreeSearchResponse(
|
||
project_id=project_id,
|
||
q=q,
|
||
total=len(matches),
|
||
results=[_metadata_node_for_search_result(snapshot, node, child_count_index) for node in page],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/metadata/tree/path", response_model=MetadataTreePathResponse)
|
||
async def project_metadata_tree_path(project_id: str, node_id: str) -> MetadataTreePathResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
path = _metadata_tree_path_for_node(snapshot, node_id)
|
||
if not path:
|
||
raise HTTPException(status_code=404, detail=f"Metadata tree path not found: {node_id}")
|
||
return MetadataTreePathResponse(
|
||
project_id=project_id,
|
||
node_id=node_id,
|
||
path=path,
|
||
steps=_metadata_tree_path_steps(snapshot, path),
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/flowchart", response_model=ProjectFlowchartResponse)
|
||
async def project_flowchart(
|
||
project_id: str,
|
||
focus: str | None = None,
|
||
depth: int = 1,
|
||
limit: int = 80,
|
||
) -> ProjectFlowchartResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
normalized_depth = min(max(depth, 1), 3)
|
||
normalized_limit = min(max(limit, 20), 240)
|
||
if focus:
|
||
focused = _flowchart_for_focus(snapshot, focus, normalized_depth, normalized_limit)
|
||
if focused is not None:
|
||
return focused
|
||
return _flowchart_overview(snapshot, normalized_limit)
|
||
|
||
|
||
@app.get("/runtime")
|
||
async def runtime() -> dict[str, str]:
|
||
return {"status": "configured", "adapter_url": os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010")}
|
||
|
||
|
||
@app.get("/runtime/platform")
|
||
async def runtime_platform() -> dict:
|
||
url = os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010").rstrip("/")
|
||
try:
|
||
with urllib.request.urlopen(f"{url}/runtime/platform", timeout=5) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
|
||
mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock")
|
||
return {
|
||
"mode": mode,
|
||
"platform_found": False,
|
||
"execution_enabled": False,
|
||
"capabilities": ["mock_normalized_project"] if mode == "mock" else [],
|
||
"diagnostics": [f"Runtime adapter platform check unavailable: {error}"],
|
||
}
|
||
|
||
|
||
@app.get("/storage")
|
||
async def storage() -> dict[str, str]:
|
||
return {"status": "configured", "path": _storage.root.as_posix()}
|
||
|
||
|
||
@app.post("/projects/index", response_model=IndexProjectResponse)
|
||
async def index_project_endpoint(request: IndexProjectRequest) -> IndexProjectResponse:
|
||
source_path = Path(request.path)
|
||
if not source_path.exists():
|
||
raise HTTPException(status_code=404, detail=f"Path not found: {request.path}")
|
||
|
||
snapshot = _index_and_store(source_path, request.project_id, structure_only=request.structure_only)
|
||
return IndexProjectResponse(snapshot=_summary(snapshot))
|
||
|
||
|
||
@app.post("/projects/demo/index", response_model=IndexProjectResponse)
|
||
async def index_demo_project() -> IndexProjectResponse:
|
||
demo_path = Path("examples/bsl")
|
||
if not demo_path.exists():
|
||
raise HTTPException(status_code=404, detail="Demo project not found")
|
||
snapshot = _index_and_store(demo_path, "demo")
|
||
return IndexProjectResponse(snapshot=_summary(snapshot))
|
||
|
||
|
||
@app.post("/projects/{project_id}/load", response_model=IndexProjectResponse)
|
||
async def load_project(project_id: str) -> IndexProjectResponse:
|
||
try:
|
||
snapshot = _load_snapshot_into_memory(project_id)
|
||
except FileNotFoundError as error:
|
||
raise HTTPException(status_code=404, detail=f"Stored snapshot not found: {project_id}") from error
|
||
return IndexProjectResponse(snapshot=_summary(snapshot))
|
||
|
||
|
||
@app.post("/projects/{project_id}/incremental/file", response_model=IncrementalFileResponse)
|
||
async def incremental_file(project_id: str, request: IncrementalFileRequest) -> IncrementalFileResponse:
|
||
previous = _snapshots.get(project_id)
|
||
if previous is None:
|
||
try:
|
||
previous = _storage.load_snapshot(project_id)
|
||
except FileNotFoundError as error:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error
|
||
changed_file = Path(request.path)
|
||
if not changed_file.exists():
|
||
raise HTTPException(status_code=404, detail=f"Path not found: {request.path}")
|
||
snapshot, delta = rebuild_changed_file(previous, changed_file)
|
||
graph = InMemoryProjection()
|
||
graph.project_snapshot(snapshot)
|
||
_snapshots[project_id] = snapshot
|
||
_graphs[project_id] = graph
|
||
_storage.save_snapshot(snapshot)
|
||
_append_and_persist_versions(snapshot)
|
||
neo4j_projected = False
|
||
neo4j_error: str | None = None
|
||
if project_id in _neo4j_projected_projects:
|
||
try:
|
||
await _apply_delta_to_neo4j(project_id, delta)
|
||
neo4j_projected = True
|
||
except HTTPException as error:
|
||
neo4j_error = str(error.detail)
|
||
return IncrementalFileResponse(
|
||
snapshot=_summary(snapshot),
|
||
added_nodes=len(delta.added_nodes),
|
||
updated_nodes=len(delta.updated_nodes),
|
||
removed_nodes=len(delta.removed_nodes),
|
||
added_edges=len(delta.added_edges),
|
||
removed_edges=len(delta.removed_edges),
|
||
neo4j_projected=neo4j_projected,
|
||
neo4j_error=neo4j_error,
|
||
)
|
||
|
||
|
||
@app.get("/storage/snapshots", response_model=list[StoredSnapshotInfo])
|
||
async def list_stored_snapshots() -> list[StoredSnapshotInfo]:
|
||
return _storage.list_snapshot_refs()
|
||
|
||
|
||
def _project_snapshot_or_404(project_id: str) -> SirSnapshot:
|
||
snapshot = _snapshots.get(project_id)
|
||
if snapshot is not None:
|
||
return snapshot
|
||
try:
|
||
return _load_snapshot_into_memory(project_id)
|
||
except FileNotFoundError as error:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error
|
||
|
||
|
||
@app.get("/projects/{project_id}/snapshot", response_model=SnapshotSummary)
|
||
async def get_snapshot(project_id: str) -> SnapshotSummary:
|
||
return _summary(_project_snapshot_or_404(project_id))
|
||
|
||
|
||
@app.get("/projects/{project_id}/snapshot/export")
|
||
async def export_snapshot(project_id: str) -> dict:
|
||
return _project_snapshot_or_404(project_id).model_dump(mode="json")
|
||
|
||
|
||
@app.get("/projects/{project_id}/versions", response_model=list[ObjectVersionSummary])
|
||
async def project_versions(project_id: str) -> list[ObjectVersionSummary]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
lineages = {node.lineage_id for node in snapshot.nodes}
|
||
return [
|
||
_version_summary(version)
|
||
for version in _versions.all_versions()
|
||
if version.lineage_id in lineages
|
||
]
|
||
|
||
|
||
@app.get("/versions/{lineage_id}", response_model=list[SemanticObjectVersion])
|
||
async def lineage_versions(lineage_id: str) -> list[SemanticObjectVersion]:
|
||
return _versions.history(lineage_id)
|
||
|
||
|
||
@app.get("/versions/{lineage_id}/diff", response_model=SemanticObjectDiff)
|
||
async def lineage_version_diff(
|
||
lineage_id: str,
|
||
from_version_id: str,
|
||
to_version_id: str,
|
||
) -> SemanticObjectDiff:
|
||
before = _versions.find_version(from_version_id)
|
||
after = _versions.find_version(to_version_id)
|
||
if before is None:
|
||
raise HTTPException(status_code=404, detail=f"Version not found: {from_version_id}")
|
||
if after is None:
|
||
raise HTTPException(status_code=404, detail=f"Version not found: {to_version_id}")
|
||
if before.lineage_id != lineage_id or after.lineage_id != lineage_id:
|
||
raise HTTPException(status_code=409, detail="Version lineage does not match requested lineage_id")
|
||
return diff_versions(before, after)
|
||
|
||
|
||
@app.get("/projects/{project_id}/impact/{routine_name}", response_model=ImpactResponse)
|
||
async def get_impact(project_id: str, routine_name: str) -> ImpactResponse:
|
||
graph = _graphs.get(project_id)
|
||
if graph is None:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}")
|
||
impact = routine_impact(graph, routine_name)
|
||
return ImpactResponse(
|
||
routine_name=impact.routine_name,
|
||
callers=[_named_node(node) for node in impact.callers],
|
||
callees=[_named_node(node) for node in impact.callees],
|
||
query_tables=[_named_node(node) for node in impact.query_tables],
|
||
writes=[_named_node(node) for node in impact.writes],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/objects/impact/{object_name}", response_model=ObjectImpactResponse)
|
||
async def get_object_impact(project_id: str, object_name: str) -> ObjectImpactResponse:
|
||
graph = _graphs.get(project_id)
|
||
if graph is None:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}")
|
||
impact = object_impact(graph, object_name)
|
||
if impact is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectImpactResponse(
|
||
object_name=impact.object_name,
|
||
object=_named_node(impact.object),
|
||
modules=[_named_node(node) for node in impact.modules],
|
||
routines=[_named_node(node) for node in impact.routines],
|
||
forms=[_named_node(node) for node in impact.forms],
|
||
commands=[_named_node(node) for node in impact.commands],
|
||
attributes=[_named_node(node) for node in impact.attributes],
|
||
tabular_sections=[_named_node(node) for node in impact.tabular_sections],
|
||
tabular_section_columns={
|
||
section_lineage: [_named_node(node) for node in columns]
|
||
for section_lineage, columns in impact.tabular_section_columns.items()
|
||
},
|
||
roles=[_named_node(node) for node in impact.roles],
|
||
role_access=[
|
||
RoleAccessResponse(role=_named_node(grant.role), permissions=grant.permissions)
|
||
for grant in impact.role_access
|
||
],
|
||
jobs=[_named_node(node) for node in impact.jobs],
|
||
callees=[_named_node(node) for node in impact.callees],
|
||
query_tables=[_named_node(node) for node in impact.query_tables],
|
||
writes=[_named_node(node) for node in impact.writes],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/objects/attributes/{object_name}", response_model=SearchResponse)
|
||
async def get_object_attributes(project_id: str, object_name: str) -> SearchResponse:
|
||
return _object_children_response(project_id, object_name, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE)
|
||
|
||
|
||
@app.get("/projects/{project_id}/objects/schema/{object_name}", response_model=ObjectSchemaResponse)
|
||
async def get_object_schema(project_id: str, object_name: str) -> ObjectSchemaResponse:
|
||
_snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return _object_schema_response(graph, object_node)
|
||
|
||
|
||
@app.get("/projects/{project_id}/objects/tabular-sections/{object_name}", response_model=SearchResponse)
|
||
async def get_object_tabular_sections(project_id: str, object_name: str) -> SearchResponse:
|
||
return _object_children_response(project_id, object_name, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/objects/tabular-sections/{object_name}/columns",
|
||
response_model=list[TabularSectionColumnsResponse],
|
||
)
|
||
async def get_object_tabular_section_columns(project_id: str, object_name: str) -> list[TabularSectionColumnsResponse]:
|
||
_snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION)
|
||
return [
|
||
TabularSectionColumnsResponse(
|
||
tabular_section=_named_node(section),
|
||
columns=[
|
||
_named_node(column)
|
||
for column in _graph_children(graph, section, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE)
|
||
],
|
||
)
|
||
for section in sections
|
||
]
|
||
|
||
|
||
def _object_schema_response(graph: InMemoryProjection, object_node) -> ObjectSchemaResponse:
|
||
attributes = _graph_children(graph, object_node, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE)
|
||
sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION)
|
||
return ObjectSchemaResponse(
|
||
object=_named_node(object_node),
|
||
attributes=[_named_node(attribute) for attribute in attributes],
|
||
tabular_sections=[
|
||
TabularSectionColumnsResponse(
|
||
tabular_section=_named_node(section),
|
||
columns=[
|
||
_named_node(column)
|
||
for column in _graph_children(graph, section, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE)
|
||
],
|
||
)
|
||
for section in sections
|
||
],
|
||
)
|
||
|
||
|
||
def _object_children_response(
|
||
project_id: str,
|
||
object_name: str,
|
||
edge_kind: EdgeKind,
|
||
child_kind: NodeKind,
|
||
) -> SearchResponse:
|
||
_snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
children = _graph_children(graph, object_node, edge_kind, child_kind)
|
||
return SearchResponse(
|
||
results=[_named_node(node) for node in children]
|
||
)
|
||
|
||
|
||
def _graph_children(
|
||
graph: InMemoryProjection,
|
||
source,
|
||
edge_kind: EdgeKind,
|
||
child_kind: NodeKind,
|
||
):
|
||
children = [
|
||
graph.nodes[edge.target_lineage]
|
||
for edge in graph.edges.values()
|
||
if edge.kind == edge_kind
|
||
and edge.source_lineage == source.lineage_id
|
||
and edge.target_lineage in graph.nodes
|
||
and graph.nodes[edge.target_lineage].kind == child_kind
|
||
]
|
||
return sorted(children, key=lambda item: item.qualified_name)
|
||
|
||
|
||
def _authoring_context(project_id: str, request: AuthoringContextRequest) -> AuthoringContextResponse:
|
||
snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = (
|
||
_find_graph_node(graph, request.object_name, _ACCESS_TARGET_KINDS)
|
||
if request.object_name
|
||
else _first_node(snapshot, _ACCESS_TARGET_KINDS)
|
||
)
|
||
routine_node = _find_routine_node(snapshot, request.routine_name, request.cursor_line)
|
||
source_text = request.source_text or _source_text_for_node(routine_node)
|
||
local_variables = _extract_local_variables(source_text, request.cursor_line)
|
||
parameters = _extract_parameters(source_text, routine_node.name if routine_node else request.routine_name)
|
||
object_attributes = _graph_children(graph, object_node, EdgeKind.HAS_ATTRIBUTE, NodeKind.ATTRIBUTE) if object_node else []
|
||
tabular_sections = _graph_children(graph, object_node, EdgeKind.HAS_TABULAR_SECTION, NodeKind.TABULAR_SECTION) if object_node else []
|
||
forms = _graph_children(graph, object_node, EdgeKind.HAS_FORM, NodeKind.FORM) if object_node else []
|
||
form_elements = [
|
||
element
|
||
for form in forms
|
||
for element in _graph_children(graph, form, EdgeKind.HAS_ELEMENT, NodeKind.FORM_ELEMENT)
|
||
]
|
||
commands = _graph_children(graph, object_node, EdgeKind.HAS_COMMAND, NodeKind.COMMAND) if object_node else []
|
||
query_tables = _routine_query_tables(graph, routine_node) if routine_node else []
|
||
writes = _relation_targets(graph, routine_node, EdgeKind.WRITES) if routine_node else []
|
||
privacy_markers = [
|
||
marker
|
||
for marker in _privacy.markers_for_project(project_id)
|
||
if marker.target_id
|
||
in {
|
||
*(node.lineage_id for node in object_attributes),
|
||
*(node.lineage_id for node in tabular_sections),
|
||
*((object_node.lineage_id,) if object_node else ()),
|
||
}
|
||
]
|
||
review_findings = [
|
||
finding
|
||
for finding in get_review_payload(snapshot)
|
||
if not object_node or object_node.qualified_name in str(finding.get("message", ""))
|
||
][:8]
|
||
return AuthoringContextResponse(
|
||
project_id=project_id,
|
||
object=_named_node(object_node) if object_node else None,
|
||
routine=_named_node(routine_node) if routine_node else None,
|
||
local_variables=local_variables,
|
||
parameters=parameters,
|
||
object_attributes=[_named_node(node) for node in object_attributes],
|
||
tabular_sections=[_named_node(node) for node in tabular_sections],
|
||
form_elements=[_named_node(node) for node in form_elements],
|
||
commands=[_named_node(node) for node in commands],
|
||
query_tables=[_named_node(node) for node in query_tables],
|
||
writes=[_named_node(node) for node in writes],
|
||
available_methods=_available_1c_methods(object_node, routine_node),
|
||
privacy_markers=privacy_markers,
|
||
review_findings=review_findings,
|
||
)
|
||
|
||
|
||
def _authoring_insert_text(
|
||
context: AuthoringContextResponse,
|
||
request: AuthoringCompletionPreviewRequest,
|
||
) -> str:
|
||
if request.intent == "register-write" and context.writes:
|
||
register = context.writes[0].name
|
||
return f" Движения.{register}.Записать();"
|
||
if request.intent == "fill-check" and context.object_attributes:
|
||
attribute = context.object_attributes[0].name
|
||
return "\n".join(
|
||
[
|
||
f" Если Не ЗначениеЗаполнено({attribute}) Тогда",
|
||
f" ВызватьИсключение \"Не заполнен реквизит {attribute}\";",
|
||
" КонецЕсли;",
|
||
]
|
||
)
|
||
return "\n".join(
|
||
[
|
||
" Если Отказ Тогда",
|
||
" Возврат;",
|
||
" КонецЕсли;",
|
||
]
|
||
)
|
||
|
||
|
||
def _authoring_guard_checks(
|
||
project_id: str,
|
||
context: AuthoringContextResponse,
|
||
request: AuthoringCompletionPreviewRequest,
|
||
) -> list[AuthoringGuardCheck]:
|
||
used_tokens = _operations.summarize_ai_usage(user_id=request.user_id).total_tokens
|
||
remaining = (
|
||
None
|
||
if _ai_policy.token_limit_per_day is None
|
||
else max(_ai_policy.token_limit_per_day - used_tokens, 0)
|
||
)
|
||
token_status = "OK" if remaining is None or request.estimated_tokens <= remaining else "BLOCKED"
|
||
return [
|
||
AuthoringGuardCheck(
|
||
name="preview",
|
||
status="REQUIRED",
|
||
message="Semantic diff must be reviewed before apply",
|
||
),
|
||
AuthoringGuardCheck(
|
||
name="impact",
|
||
status="OK" if context.routine or context.object else "WARNING",
|
||
message="Impact context is available" if context.routine or context.object else "No object or routine context selected",
|
||
),
|
||
AuthoringGuardCheck(
|
||
name="review",
|
||
status="WARNING" if context.review_findings else "OK",
|
||
message=f"{len(context.review_findings)} review findings in context",
|
||
),
|
||
_authoring_privacy_check(context),
|
||
AuthoringGuardCheck(
|
||
name="ai-token-budget",
|
||
status=token_status,
|
||
message="AI token budget is available" if token_status == "OK" else "AI token limit would be exceeded",
|
||
),
|
||
AuthoringGuardCheck(
|
||
name="apply",
|
||
status="BLOCKED",
|
||
message="Apply API is intentionally disabled in preview mode",
|
||
),
|
||
]
|
||
|
||
|
||
def _authoring_task_session_check(
|
||
project_id: str,
|
||
task_id: str | None,
|
||
session_id: str | None,
|
||
user_id: str | None = None,
|
||
) -> AuthoringGuardCheck:
|
||
if not task_id:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message="Task id is required for workspace apply",
|
||
)
|
||
task = _collaboration.tasks.get(task_id)
|
||
if task is None:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Task {task_id} was not found",
|
||
)
|
||
if task.project_id != project_id:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Task {task_id} belongs to project {task.project_id}",
|
||
)
|
||
if task.status.value in {"DONE", "CANCELED"}:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Task {task_id} is {task.status.value}",
|
||
)
|
||
if not session_id:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message="Session id is required for workspace apply",
|
||
)
|
||
session = _collaboration.sessions.get(session_id)
|
||
if session is None:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Session {session_id} was not found",
|
||
)
|
||
if session.task_id != task_id:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Session {session_id} belongs to task {session.task_id}",
|
||
)
|
||
if session.finished_at is not None:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Session {session_id} is already finished",
|
||
)
|
||
if user_id and session.user_id != user_id:
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="BLOCKED",
|
||
message=f"Session {session_id} belongs to user {session.user_id}",
|
||
)
|
||
return AuthoringGuardCheck(
|
||
name="task-session",
|
||
status="OK",
|
||
message=f"Task {task_id} and session {session_id} are active for project {project_id}",
|
||
)
|
||
|
||
|
||
def _authoring_rbac_check(user_id: str | None) -> AuthoringGuardCheck:
|
||
if not user_id:
|
||
return AuthoringGuardCheck(
|
||
name="rbac",
|
||
status="BLOCKED",
|
||
message="User id is required for workspace apply",
|
||
)
|
||
if not _rbac.is_allowed(user_id, Permission.APPLY_AUTHORING_CHANGE):
|
||
return AuthoringGuardCheck(
|
||
name="rbac",
|
||
status="BLOCKED",
|
||
message=f"User {user_id} is not allowed to apply authoring changes",
|
||
)
|
||
return AuthoringGuardCheck(
|
||
name="rbac",
|
||
status="OK",
|
||
message=f"User {user_id} can apply authoring changes",
|
||
)
|
||
|
||
|
||
def _authoring_privacy_check(context: AuthoringContextResponse) -> AuthoringGuardCheck:
|
||
blocked_markers = [
|
||
marker
|
||
for marker in context.privacy_markers
|
||
if marker.classification in {PrivacyClassification.PERSONAL_DATA, PrivacyClassification.SECRET}
|
||
]
|
||
if blocked_markers:
|
||
return AuthoringGuardCheck(
|
||
name="privacy",
|
||
status="BLOCKED",
|
||
message=f"{len(blocked_markers)} sensitive privacy markers require explicit local-only handling before apply",
|
||
)
|
||
if context.privacy_markers:
|
||
return AuthoringGuardCheck(
|
||
name="privacy",
|
||
status="WARNING",
|
||
message=f"{len(context.privacy_markers)} privacy markers in context",
|
||
)
|
||
return AuthoringGuardCheck(name="privacy", status="OK", message="No privacy markers in context")
|
||
|
||
|
||
def _authoring_target_node(snapshot: SirSnapshot, request: AuthoringSemanticDiffPreviewRequest):
|
||
if request.target_lineage_id:
|
||
found = next((node for node in snapshot.nodes if node.lineage_id == request.target_lineage_id), None)
|
||
if found is not None:
|
||
return found
|
||
if request.routine_name:
|
||
found = _find_routine_node(snapshot, request.routine_name, None)
|
||
if found is not None:
|
||
return found
|
||
if request.object_name:
|
||
wanted = request.object_name.lower()
|
||
found = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in _ACCESS_TARGET_KINDS and (node.name.lower() == wanted or node.qualified_name.lower() == wanted)
|
||
),
|
||
None,
|
||
)
|
||
if found is not None:
|
||
return found
|
||
if request.source_path:
|
||
path = str(Path(request.source_path))
|
||
found = next((node for node in snapshot.nodes if str(Path(node.source_ref.source_path)) == path), None)
|
||
if found is not None:
|
||
return found
|
||
return None
|
||
|
||
|
||
def _text_semantic_diff(original_text: str, proposed_text: str) -> list[AuthoringDiffLine]:
|
||
original_lines = original_text.splitlines()
|
||
proposed_lines = proposed_text.splitlines()
|
||
matcher = SequenceMatcher(a=original_lines, b=proposed_lines)
|
||
diff: list[AuthoringDiffLine] = []
|
||
for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes():
|
||
if tag == "equal":
|
||
continue
|
||
if tag in {"replace", "delete"}:
|
||
diff.extend(
|
||
AuthoringDiffLine(kind="REMOVE", text=line)
|
||
for line in original_lines[old_start:old_end]
|
||
)
|
||
if tag in {"replace", "insert"}:
|
||
diff.extend(
|
||
AuthoringDiffLine(kind="ADD", text=line)
|
||
for line in proposed_lines[new_start:new_end]
|
||
)
|
||
return diff
|
||
|
||
|
||
def _affected_nodes_for_diff(snapshot: SirSnapshot, target, source_path: str | None):
|
||
if target is None and source_path is None:
|
||
return []
|
||
affected = []
|
||
source_path_value = str(Path(source_path)) if source_path else None
|
||
for node in snapshot.nodes:
|
||
same_target = target is not None and node.lineage_id == target.lineage_id
|
||
same_file = source_path_value is not None and str(Path(node.source_ref.source_path)) == source_path_value
|
||
inside_target_range = (
|
||
target is not None
|
||
and node.source_ref.source_path == target.source_ref.source_path
|
||
and target.source_ref.line_start is not None
|
||
and target.source_ref.line_end is not None
|
||
and node.source_ref.line_start is not None
|
||
and target.source_ref.line_start <= node.source_ref.line_start <= target.source_ref.line_end
|
||
)
|
||
if same_target or same_file or inside_target_range:
|
||
affected.append(node)
|
||
return sorted(affected, key=lambda item: (item.source_ref.line_start or 0, item.qualified_name))[:20]
|
||
|
||
|
||
def _authoring_version_preview(
|
||
target,
|
||
proposed_text: str,
|
||
task_id: str | None,
|
||
session_id: str | None,
|
||
) -> AuthoringVersionPreview | None:
|
||
if target is None:
|
||
return None
|
||
object_hash = stable_hash(
|
||
json.dumps(
|
||
{
|
||
"lineage_id": target.lineage_id,
|
||
"semantic_id": target.semantic_id,
|
||
"proposed_text": proposed_text,
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
)
|
||
current = _versions.latest(target.lineage_id)
|
||
return AuthoringVersionPreview(
|
||
lineage_id=target.lineage_id,
|
||
semantic_id=target.semantic_id,
|
||
current_version_id=current.version_id if current else None,
|
||
next_version_id=f"version.{target.lineage_id}.{object_hash}",
|
||
object_hash=object_hash,
|
||
task_id=task_id,
|
||
session_id=session_id,
|
||
apply_available=False,
|
||
)
|
||
|
||
|
||
def _authoring_semantic_diff_preview(
|
||
project_id: str,
|
||
request: AuthoringSemanticDiffPreviewRequest,
|
||
) -> AuthoringSemanticDiffPreviewResponse:
|
||
snapshot, _graph = _snapshot_and_graph(project_id)
|
||
target = _authoring_target_node(snapshot, request)
|
||
diff = _text_semantic_diff(request.original_text, request.proposed_text)
|
||
added_lines = sum(1 for line in diff if line.kind == "ADD")
|
||
removed_lines = sum(1 for line in diff if line.kind == "REMOVE")
|
||
affected_nodes = _affected_nodes_for_diff(snapshot, target, request.source_path)
|
||
context = _authoring_context(
|
||
project_id,
|
||
AuthoringContextRequest(
|
||
object_name=request.object_name,
|
||
routine_name=request.routine_name,
|
||
cursor_line=None,
|
||
source_text=request.proposed_text,
|
||
),
|
||
)
|
||
checks = _authoring_guard_checks(
|
||
project_id,
|
||
context,
|
||
AuthoringCompletionPreviewRequest(
|
||
object_name=request.object_name,
|
||
routine_name=request.routine_name,
|
||
source_text=request.proposed_text,
|
||
estimated_tokens=request.estimated_tokens,
|
||
user_id=request.user_id,
|
||
),
|
||
)
|
||
checks.append(_authoring_task_session_check(project_id, request.task_id, request.session_id, request.user_id))
|
||
checks.append(_authoring_rbac_check(request.user_id))
|
||
version_preview = _authoring_version_preview(target, request.proposed_text, request.task_id, request.session_id)
|
||
return AuthoringSemanticDiffPreviewResponse(
|
||
project_id=project_id,
|
||
target=_named_node(target) if target else None,
|
||
changed=bool(diff),
|
||
added_lines=added_lines,
|
||
removed_lines=removed_lines,
|
||
semantic_diff=diff,
|
||
affected_nodes=[_named_node(node) for node in affected_nodes],
|
||
checks=checks,
|
||
version_preview=version_preview,
|
||
)
|
||
|
||
|
||
def _persist_authoring_version(
|
||
preview: AuthoringSemanticDiffPreviewResponse,
|
||
request: AuthoringApplyChangeSetRequest,
|
||
) -> SemanticObjectVersion:
|
||
if preview.version_preview is None:
|
||
raise ValueError("version preview is required")
|
||
previous = _versions.latest(preview.version_preview.lineage_id)
|
||
version = SemanticObjectVersion(
|
||
version_id=preview.version_preview.next_version_id,
|
||
lineage_id=preview.version_preview.lineage_id,
|
||
semantic_id=preview.version_preview.semantic_id,
|
||
object_hash=preview.version_preview.object_hash,
|
||
task_id=request.task_id,
|
||
session_id=request.session_id,
|
||
parent_version_id=previous.version_id if previous else None,
|
||
payload={
|
||
"kind": "AUTHORING_CHANGE_SET",
|
||
"original_text": request.original_text,
|
||
"proposed_text": request.proposed_text,
|
||
"semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff],
|
||
"affected_nodes": [node.model_dump(mode="json") for node in preview.affected_nodes],
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
},
|
||
)
|
||
stored = _versions.upsert_version(version)
|
||
_storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json"))
|
||
return stored
|
||
|
||
|
||
def _authoring_change_summaries(project_id: str) -> list[AuthoringChangeSummary]:
|
||
summaries: list[AuthoringChangeSummary] = []
|
||
for payload in _storage.list_documents("authoring_changes"):
|
||
if payload.get("project_id") != project_id:
|
||
continue
|
||
preview = payload.get("preview", {})
|
||
version = payload.get("version", {})
|
||
request = payload.get("request", {})
|
||
summaries.append(
|
||
AuthoringChangeSummary(
|
||
change_id=str(payload["change_id"]),
|
||
project_id=str(payload["project_id"]),
|
||
status=str(payload["status"]),
|
||
target=preview.get("target"),
|
||
version_id=str(version.get("version_id", "")),
|
||
approved_by=str(payload.get("approved_by", "")),
|
||
approval_note=payload.get("approval_note"),
|
||
task_id=request.get("task_id"),
|
||
session_id=request.get("session_id"),
|
||
added_lines=int(preview.get("added_lines", 0)),
|
||
removed_lines=int(preview.get("removed_lines", 0)),
|
||
production_applied=bool(payload.get("production_applied", False)),
|
||
)
|
||
)
|
||
return sorted(summaries, key=lambda item: item.change_id, reverse=True)
|
||
|
||
|
||
def _authoring_change_payload(project_id: str, change_id: str) -> dict:
|
||
for payload in _storage.list_documents("authoring_changes"):
|
||
if payload.get("project_id") == project_id and payload.get("change_id") == change_id:
|
||
return payload
|
||
raise HTTPException(status_code=404, detail=f"Authoring change not found: {change_id}")
|
||
|
||
|
||
def _authoring_rollback_preview(project_id: str, change_id: str) -> AuthoringRollbackPreviewResponse:
|
||
payload = _authoring_change_payload(project_id, change_id)
|
||
version_payload = payload["version"]
|
||
version_body = version_payload["payload"]
|
||
if "original_text" in version_body and "proposed_text" in version_body:
|
||
semantic_diff = _text_semantic_diff(version_body["proposed_text"], version_body["original_text"])
|
||
elif version_body.get("kind") == "METADATA_OBJECT_DRAFT":
|
||
semantic_diff = [
|
||
AuthoringDiffLine(kind="REMOVE", text=str(line.get("text", "")))
|
||
for line in reversed(version_body.get("semantic_diff", []))
|
||
if line.get("kind") == "ADD"
|
||
]
|
||
else:
|
||
raise HTTPException(status_code=409, detail=f"Rollback is not supported for version kind: {version_body.get('kind')}")
|
||
object_hash = stable_hash(
|
||
json.dumps(
|
||
{
|
||
"rollback_of": version_payload["version_id"],
|
||
"semantic_diff": [line.model_dump(mode="json") for line in semantic_diff],
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
)
|
||
rollback_version_id = f"version.{version_payload['lineage_id']}.{object_hash}"
|
||
return AuthoringRollbackPreviewResponse(
|
||
project_id=project_id,
|
||
change_id=change_id,
|
||
original_version_id=version_payload["version_id"],
|
||
rollback_version_id=rollback_version_id,
|
||
target=payload["preview"].get("target"),
|
||
semantic_diff=semantic_diff,
|
||
checks=[
|
||
AuthoringGuardCheck(name="preview", status="REQUIRED", message="Rollback diff must be reviewed before apply"),
|
||
AuthoringGuardCheck(name="apply", status="READY", message="Rollback can be applied to SFERA workspace history"),
|
||
],
|
||
apply_available=True,
|
||
)
|
||
|
||
|
||
def _persist_authoring_rollback(
|
||
project_id: str,
|
||
change_payload: dict,
|
||
preview: AuthoringRollbackPreviewResponse,
|
||
request: AuthoringApplyRollbackRequest,
|
||
) -> tuple[SemanticObjectVersion, str]:
|
||
version_payload = change_payload["version"]
|
||
original_payload = version_payload["payload"]
|
||
object_hash = preview.rollback_version_id.rsplit(".", 1)[-1]
|
||
previous = _versions.latest(version_payload["lineage_id"])
|
||
if original_payload.get("kind") == "METADATA_OBJECT_DRAFT":
|
||
rollback_payload = {
|
||
"kind": "METADATA_OBJECT_DRAFT_ROLLBACK",
|
||
"rollback_of_change_id": change_payload["change_id"],
|
||
"rollback_of_version_id": version_payload["version_id"],
|
||
"draft": original_payload.get("draft", {}),
|
||
"semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff],
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
}
|
||
else:
|
||
rollback_payload = {
|
||
"kind": "AUTHORING_ROLLBACK",
|
||
"rollback_of_change_id": change_payload["change_id"],
|
||
"rollback_of_version_id": version_payload["version_id"],
|
||
"original_text": original_payload["proposed_text"],
|
||
"proposed_text": original_payload["original_text"],
|
||
"semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff],
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
}
|
||
version = SemanticObjectVersion(
|
||
version_id=preview.rollback_version_id,
|
||
lineage_id=version_payload["lineage_id"],
|
||
semantic_id=version_payload["semantic_id"],
|
||
object_hash=object_hash,
|
||
task_id=request.task_id or change_payload.get("request", {}).get("task_id"),
|
||
session_id=request.session_id or change_payload.get("request", {}).get("session_id"),
|
||
parent_version_id=previous.version_id if previous else version_payload["version_id"],
|
||
payload=rollback_payload,
|
||
)
|
||
stored = _versions.upsert_version(version)
|
||
_storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json"))
|
||
rollback_change_id = f"rollback.{change_payload['change_id']}"
|
||
payload = {
|
||
"change_id": rollback_change_id,
|
||
"project_id": project_id,
|
||
"status": "ROLLED_BACK_TO_WORKSPACE",
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
"request": request.model_dump(mode="json"),
|
||
"preview": {
|
||
"target": preview.target.model_dump(mode="json") if preview.target else None,
|
||
"added_lines": sum(1 for line in preview.semantic_diff if line.kind == "ADD"),
|
||
"removed_lines": sum(1 for line in preview.semantic_diff if line.kind == "REMOVE"),
|
||
},
|
||
"rollback_preview": preview.model_dump(mode="json"),
|
||
"version": stored.model_dump(mode="json"),
|
||
}
|
||
path = _storage.write_document("authoring_changes", rollback_change_id, payload)
|
||
return stored, path.as_posix()
|
||
|
||
|
||
_METADATA_SPEC_PREFIXES = {
|
||
"COMMON_MODULE": "ОбщийМодуль.",
|
||
"CONSTANT": "Константа.",
|
||
"CATALOG": "Справочник.",
|
||
"DOCUMENT": "Документ.",
|
||
"DOCUMENT_JOURNAL": "ЖурналДокументов.",
|
||
"ENUM": "Перечисление.",
|
||
"REPORT": "Отчет.",
|
||
"DATA_PROCESSOR": "Обработка.",
|
||
"CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик.",
|
||
"CHART_OF_ACCOUNTS": "ПланСчетов.",
|
||
"CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета.",
|
||
"INFORMATION_REGISTER": "РегистрСведений.",
|
||
"ACCUMULATION_REGISTER": "РегистрНакопления.",
|
||
"ACCOUNTING_REGISTER": "РегистрБухгалтерии.",
|
||
"CALCULATION_REGISTER": "РегистрРасчета.",
|
||
"BUSINESS_PROCESS": "БизнесПроцесс.",
|
||
"TASK": "Задача.",
|
||
"EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных.",
|
||
"EXCHANGE_PLAN": "ПланОбмена.",
|
||
"EVENT_SUBSCRIPTION": "ПодпискаНаСобытие.",
|
||
"SCHEDULED_JOB": "РегламентноеЗадание.",
|
||
}
|
||
|
||
|
||
_METADATA_SPEC_NODE_KINDS = {
|
||
"COMMON_MODULE": {NodeKind.COMMON_MODULE},
|
||
"CATALOG": {NodeKind.CATALOG},
|
||
"DOCUMENT": {NodeKind.DOCUMENT},
|
||
"INFORMATION_REGISTER": {NodeKind.REGISTER},
|
||
"ACCUMULATION_REGISTER": {NodeKind.REGISTER},
|
||
"ACCOUNTING_REGISTER": {NodeKind.REGISTER},
|
||
"CALCULATION_REGISTER": {NodeKind.REGISTER},
|
||
"BUSINESS_PROCESS": {NodeKind.BUSINESS_PROCESS},
|
||
"TASK": {NodeKind.TASK},
|
||
"EXCHANGE_PLAN": {NodeKind.EXCHANGE_PLAN},
|
||
"SCHEDULED_JOB": {NodeKind.SCHEDULED_JOB},
|
||
}
|
||
if _EVENT_SUBSCRIPTION_KIND is not None:
|
||
_METADATA_SPEC_NODE_KINDS["EVENT_SUBSCRIPTION"] = {_EVENT_SUBSCRIPTION_KIND}
|
||
|
||
|
||
def _common_branch_spec_code(label: str) -> str | None:
|
||
spec = METADATA_TYPE_BY_BRANCH.get(label)
|
||
if spec is None:
|
||
return None
|
||
if spec.code in {"COMMON", "EXTENSION"}:
|
||
return None
|
||
return spec.code
|
||
|
||
|
||
def _metadata_spec_by_code(spec_code: str):
|
||
return METADATA_TYPE_BY_CODE.get(spec_code)
|
||
|
||
|
||
def _metadata_tree_node(
|
||
node_id: str,
|
||
label: str,
|
||
kind: str,
|
||
icon: str,
|
||
*,
|
||
qualified_name: str | None = None,
|
||
children: list[MetadataTreeNodeResponse] | None = None,
|
||
count: int | None = None,
|
||
has_more: bool = False,
|
||
) -> MetadataTreeNodeResponse:
|
||
child_nodes = children or []
|
||
return MetadataTreeNodeResponse(
|
||
id=node_id,
|
||
label=label,
|
||
kind=kind,
|
||
icon=icon,
|
||
qualified_name=qualified_name,
|
||
count=count if count is not None else sum(child.count or 1 for child in child_nodes),
|
||
loaded_count=len(child_nodes),
|
||
has_more=has_more,
|
||
children=child_nodes,
|
||
)
|
||
|
||
|
||
def _configuration_like_empty_root(root_id: str, label: str, kind: str) -> MetadataTreeNodeResponse:
|
||
common_children = [
|
||
_metadata_tree_node(f"{root_id}.common.{label}", label, "COMMON_BRANCH", _metadata_icon_for_common_branch(label))
|
||
for label in COMMON_BRANCH_CHILDREN
|
||
]
|
||
spec_branches = [
|
||
_metadata_tree_node(f"{root_id}.branch.{spec.code}", spec.tree_branch, spec.code, spec.icon, count=0)
|
||
for spec in METADATA_TYPE_SPECS
|
||
if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN
|
||
]
|
||
return _metadata_tree_node(
|
||
root_id,
|
||
label,
|
||
kind,
|
||
"configuration",
|
||
children=[
|
||
_metadata_tree_node(f"{root_id}.info", "Сведения", "CONFIGURATION_INFO", "configuration"),
|
||
_metadata_tree_node(f"{root_id}.common", "Общие", "COMMON", "common", children=common_children),
|
||
*spec_branches,
|
||
],
|
||
)
|
||
|
||
|
||
def _metadata_common_branch(
|
||
snapshot: SirSnapshot,
|
||
label: str,
|
||
object_limit_per_branch: int,
|
||
) -> MetadataTreeNodeResponse:
|
||
spec_code = _common_branch_spec_code(label)
|
||
if spec_code is None:
|
||
return _metadata_tree_node(
|
||
f"common.{label}",
|
||
label,
|
||
"COMMON_BRANCH",
|
||
_metadata_icon_for_common_branch(label),
|
||
count=0,
|
||
has_more=False,
|
||
)
|
||
|
||
spec = _metadata_spec_by_code(spec_code)
|
||
if spec is None:
|
||
return _metadata_tree_node(
|
||
f"common.{label}",
|
||
label,
|
||
"COMMON_BRANCH",
|
||
_metadata_icon_for_common_branch(label),
|
||
count=0,
|
||
has_more=False,
|
||
)
|
||
|
||
object_nodes = sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
visible_nodes = object_nodes[:object_limit_per_branch] if object_limit_per_branch > 0 else []
|
||
child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in visible_nodes])
|
||
children = [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in visible_nodes]
|
||
return _metadata_tree_node(
|
||
f"common.{label}",
|
||
label,
|
||
"COMMON_BRANCH",
|
||
_metadata_icon_for_common_branch(label),
|
||
children=children,
|
||
count=len(object_nodes),
|
||
has_more=len(visible_nodes) < len(object_nodes),
|
||
)
|
||
|
||
|
||
def _conditional_configuration_like_roots(project_id: str | None) -> list[MetadataTreeNodeResponse]:
|
||
if not project_id:
|
||
return []
|
||
state = _project_setup.get(project_id, {})
|
||
settings = ProjectSettingsRequest.model_validate(state.get("settings", {}))
|
||
source = state.get("current_source") or (settings.structure_source.value if settings.structure_source else None)
|
||
roots: list[MetadataTreeNodeResponse] = []
|
||
if source == ImportSourceKind.CONTEXT_ONLY.value:
|
||
roots.append(_configuration_like_empty_root("context-configuration", "Context-only configuration", "CONTEXT_CONFIGURATION"))
|
||
if source == ImportSourceKind.REFERENCE_CONFIGURATION.value:
|
||
roots.append(_configuration_like_empty_root("reference-configuration", "Reference configuration", "REFERENCE_CONFIGURATION"))
|
||
return roots
|
||
|
||
|
||
def _project_metadata_tree_response(snapshot: SirSnapshot, object_limit_per_branch: int = 200) -> MetadataTreeNodeResponse:
|
||
common_children = [
|
||
_metadata_common_branch(snapshot, label, object_limit_per_branch)
|
||
for label in COMMON_BRANCH_CHILDREN
|
||
]
|
||
spec_branches = [
|
||
_metadata_tree_branch_for_spec(snapshot, spec, object_limit_per_branch)
|
||
for spec in METADATA_TYPE_SPECS
|
||
if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN
|
||
]
|
||
main_configuration = _metadata_tree_node(
|
||
"main-configuration",
|
||
"Основная конфигурация",
|
||
"MAIN_CONFIGURATION",
|
||
"configuration",
|
||
children=[
|
||
_metadata_tree_node("main-configuration.info", "Сведения", "CONFIGURATION_INFO", "configuration"),
|
||
_metadata_tree_node("common", "Общие", "COMMON", "common", children=common_children),
|
||
*spec_branches,
|
||
],
|
||
)
|
||
sfera = _metadata_tree_node(
|
||
"sfera",
|
||
"SFERA",
|
||
"SFERA_ROOT",
|
||
"service",
|
||
children=[
|
||
_metadata_tree_node("sfera-objects", "Наши объекты SFERA", "SFERA_OBJECTS", "service"),
|
||
*[
|
||
_metadata_tree_node(f"sfera.{label}", label, "SFERA_SECTION", "service")
|
||
for label in ("Блок-схема", "Задачи разработки", "Проверки", "Версии", "Инциденты", "Знания", "Настройки")
|
||
],
|
||
],
|
||
)
|
||
return _metadata_tree_node(
|
||
f"project.{snapshot.project_id}",
|
||
"Проект",
|
||
"PROJECT",
|
||
"project",
|
||
children=[
|
||
main_configuration,
|
||
_metadata_tree_node("extension.placeholder", "Расширение: <Имя>", "EXTENSION", "extension"),
|
||
*_conditional_configuration_like_roots(snapshot.project_id),
|
||
sfera,
|
||
_metadata_tree_node("environments", "Среды", "ENVIRONMENTS", "service"),
|
||
],
|
||
)
|
||
|
||
|
||
def _project_metadata_tree_response_from_normalized(
|
||
normalized: NormalizedProject,
|
||
object_limit_per_branch: int = 200,
|
||
) -> MetadataTreeNodeResponse:
|
||
main_configuration = _normalized_configuration_tree_root(
|
||
"main-configuration",
|
||
"Основная конфигурация",
|
||
"MAIN_CONFIGURATION",
|
||
normalized.configuration.groups,
|
||
object_limit_per_branch,
|
||
)
|
||
visible_extensions = (
|
||
normalized.configuration.extensions[:object_limit_per_branch]
|
||
if object_limit_per_branch > 0
|
||
else normalized.configuration.extensions
|
||
)
|
||
extension_roots = [
|
||
_normalized_configuration_tree_root(
|
||
f"extension.{stable_hash(extension.name)}",
|
||
f"Расширение: {extension.name}",
|
||
"EXTENSION",
|
||
extension.groups,
|
||
object_limit_per_branch,
|
||
icon="extension",
|
||
info={
|
||
"qualified_name": extension.qualified_name,
|
||
"version": extension.version,
|
||
**dict(getattr(extension, "attributes", {}) or {}),
|
||
},
|
||
)
|
||
for extension in visible_extensions
|
||
]
|
||
if not extension_roots:
|
||
extension_roots = [
|
||
_metadata_tree_node("extension.placeholder", "Расширение: <Имя>", "EXTENSION", "extension")
|
||
]
|
||
sfera = _metadata_tree_node(
|
||
"sfera",
|
||
"SFERA",
|
||
"SFERA_ROOT",
|
||
"service",
|
||
children=[
|
||
_metadata_tree_node("sfera-objects", "Наши объекты SFERA", "SFERA_OBJECTS", "service"),
|
||
*[
|
||
_metadata_tree_node(f"sfera.{label}", label, "SFERA_SECTION", "service")
|
||
for label in ("Блок-схема", "Задачи разработки", "Проверки", "Версии", "Инциденты", "Знания", "Настройки")
|
||
],
|
||
],
|
||
)
|
||
return _metadata_tree_node(
|
||
f"project.{normalized.project_id or 'normalized'}",
|
||
"Проект",
|
||
"PROJECT",
|
||
"project",
|
||
children=[
|
||
main_configuration,
|
||
*extension_roots,
|
||
*_conditional_configuration_like_roots(normalized.project_id),
|
||
sfera,
|
||
_metadata_tree_node("environments", "Среды", "ENVIRONMENTS", "service"),
|
||
],
|
||
)
|
||
|
||
|
||
def _normalized_configuration_tree_root(
|
||
root_id: str,
|
||
label: str,
|
||
kind: str,
|
||
groups,
|
||
object_limit_per_branch: int,
|
||
*,
|
||
icon: str = "configuration",
|
||
info: dict | None = None,
|
||
) -> MetadataTreeNodeResponse:
|
||
common_children = [
|
||
_normalized_common_branch(groups, common_label, object_limit_per_branch, root_id=root_id)
|
||
for common_label in COMMON_BRANCH_CHILDREN
|
||
]
|
||
group_branches = [
|
||
_normalized_group_tree_branch(group, object_limit_per_branch, root_id=root_id)
|
||
for group in groups
|
||
if group.name != "Расширения" and group.name not in COMMON_BRANCH_CHILDREN
|
||
]
|
||
info_label = "Сведения"
|
||
if info:
|
||
version = info.get("version") or info.get("Version")
|
||
if version:
|
||
info_label = f"Сведения ({version})"
|
||
return _metadata_tree_node(
|
||
root_id,
|
||
label,
|
||
kind,
|
||
icon,
|
||
children=[
|
||
_metadata_tree_node(f"{root_id}.info", info_label, "CONFIGURATION_INFO", icon),
|
||
_metadata_tree_node(f"{root_id}.common", "Общие", "COMMON", "common", children=common_children),
|
||
*group_branches,
|
||
],
|
||
)
|
||
|
||
|
||
def _normalized_group_tree_branch(group, object_limit_per_branch: int, *, root_id: str = "main-configuration") -> MetadataTreeNodeResponse:
|
||
visible_objects = group.objects[:object_limit_per_branch] if object_limit_per_branch > 0 else []
|
||
node_id = _normalized_branch_node_id(root_id, group.name)
|
||
return _metadata_tree_node(
|
||
node_id,
|
||
group.name,
|
||
",".join(group.object_kinds),
|
||
"metadata",
|
||
children=[_normalized_object_tree_node(item) for item in visible_objects],
|
||
count=len(group.objects),
|
||
has_more=len(visible_objects) < len(group.objects),
|
||
)
|
||
|
||
|
||
def _normalized_common_branch(groups, label: str, object_limit_per_branch: int, *, root_id: str = "main-configuration") -> MetadataTreeNodeResponse:
|
||
node_id = _normalized_common_node_id(root_id, label)
|
||
group = next((item for item in groups if item.name == label), None)
|
||
if group is None:
|
||
return _metadata_tree_node(
|
||
node_id,
|
||
label,
|
||
"COMMON_BRANCH",
|
||
_metadata_icon_for_common_branch(label),
|
||
count=0,
|
||
has_more=False,
|
||
)
|
||
|
||
visible_objects = group.objects[:object_limit_per_branch] if object_limit_per_branch > 0 else []
|
||
return _metadata_tree_node(
|
||
node_id,
|
||
label,
|
||
"COMMON_BRANCH",
|
||
_metadata_icon_for_common_branch(label),
|
||
children=[_normalized_object_tree_node(item) for item in visible_objects],
|
||
count=len(group.objects),
|
||
has_more=len(visible_objects) < len(group.objects),
|
||
)
|
||
|
||
|
||
def _normalized_object_tree_node(item) -> MetadataTreeNodeResponse:
|
||
spec = METADATA_TYPE_BY_CODE.get(item.object_kind)
|
||
expected_groups = spec.child_groups if spec is not None else _NORMALIZED_CHILD_GROUPS_BY_LABEL.keys()
|
||
child_groups = [
|
||
(label, len(getattr(item, field_name, [])), icon)
|
||
for label in expected_groups
|
||
for field_name, icon in [_NORMALIZED_CHILD_GROUPS_BY_LABEL.get(label, ("", "metadata"))]
|
||
if field_name and len(getattr(item, field_name, [])) > 0
|
||
]
|
||
children = [
|
||
_metadata_tree_node(
|
||
f"normalized.{stable_hash(item.qualified_name)}.{label}",
|
||
label,
|
||
"METADATA_CHILD_GROUP",
|
||
icon,
|
||
count=count,
|
||
has_more=count > 0,
|
||
)
|
||
for label, count, icon in child_groups
|
||
if count > 0
|
||
]
|
||
return _metadata_tree_node(
|
||
f"normalized.{stable_hash(item.qualified_name)}",
|
||
item.name,
|
||
item.object_kind,
|
||
_normalized_object_icon(item.object_kind),
|
||
qualified_name=item.qualified_name,
|
||
children=children,
|
||
count=len(children),
|
||
)
|
||
|
||
|
||
def _normalized_metadata_tree_children_for_node(
|
||
normalized: NormalizedProject,
|
||
*,
|
||
node_id: str,
|
||
offset: int,
|
||
limit: int,
|
||
) -> tuple[list[MetadataTreeNodeResponse], int] | None:
|
||
if node_id.startswith("normalized.branch."):
|
||
group = _normalized_group_by_branch_id(normalized, node_id)
|
||
if group is None:
|
||
raise HTTPException(status_code=404, detail=f"Metadata branch not found: {node_id}")
|
||
return _slice_normalized_objects(group.objects, offset, limit)
|
||
|
||
if node_id.startswith("common."):
|
||
group = _normalized_group_by_name(normalized, node_id.removeprefix("common."))
|
||
if group is None:
|
||
return [], 0
|
||
return _slice_normalized_objects(group.objects, offset, limit)
|
||
|
||
extension_scope = _normalized_extension_scope_for_node(normalized, node_id)
|
||
if extension_scope is not None:
|
||
extension, scoped_id = extension_scope
|
||
if scoped_id.startswith("branch."):
|
||
group = _normalized_group_by_branch_id_for_groups(extension.groups, scoped_id)
|
||
if group is None:
|
||
raise HTTPException(status_code=404, detail=f"Extension metadata branch not found: {node_id}")
|
||
return _slice_normalized_objects(group.objects, offset, limit)
|
||
if scoped_id.startswith("common."):
|
||
group = _normalized_group_by_name_for_groups(extension.groups, scoped_id.removeprefix("common."))
|
||
if group is None:
|
||
return [], 0
|
||
return _slice_normalized_objects(group.objects, offset, limit)
|
||
|
||
child_group = _normalized_child_group_for_node(normalized, node_id)
|
||
if child_group is not None:
|
||
parts, icon = child_group
|
||
page = parts[offset : offset + limit]
|
||
return [_normalized_part_tree_node(part, icon) for part in page], len(parts)
|
||
|
||
if node_id.startswith("normalized."):
|
||
raise HTTPException(status_code=404, detail=f"Metadata tree node not found: {node_id}")
|
||
|
||
return None
|
||
|
||
|
||
def _normalized_group_by_branch_id(normalized: NormalizedProject, node_id: str):
|
||
return _normalized_group_by_branch_id_for_groups(normalized.configuration.groups, node_id.removeprefix("normalized."))
|
||
|
||
|
||
def _normalized_group_by_branch_id_for_groups(groups, node_id: str):
|
||
return next(
|
||
(
|
||
group
|
||
for group in groups
|
||
if node_id == f"branch.{stable_hash(group.name)}"
|
||
),
|
||
None,
|
||
)
|
||
|
||
|
||
def _normalized_group_by_name(normalized: NormalizedProject, name: str):
|
||
return _normalized_group_by_name_for_groups(normalized.configuration.groups, name)
|
||
|
||
|
||
def _normalized_group_by_name_for_groups(groups, name: str):
|
||
return next((group for group in groups if group.name == name), None)
|
||
|
||
|
||
def _normalized_extension_scope_for_node(normalized: NormalizedProject, node_id: str):
|
||
prefix = "extension."
|
||
if not node_id.startswith(prefix):
|
||
return None
|
||
payload = node_id.removeprefix(prefix)
|
||
if "." not in payload:
|
||
return None
|
||
extension_hash, scoped_id = payload.split(".", 1)
|
||
extension = next(
|
||
(item for item in normalized.configuration.extensions if stable_hash(item.name) == extension_hash),
|
||
None,
|
||
)
|
||
if extension is None:
|
||
return None
|
||
return extension, scoped_id
|
||
|
||
|
||
def _normalized_branch_node_id(root_id: str, group_name: str) -> str:
|
||
if root_id == "main-configuration":
|
||
return f"normalized.branch.{stable_hash(group_name)}"
|
||
return f"{root_id}.branch.{stable_hash(group_name)}"
|
||
|
||
|
||
def _normalized_common_node_id(root_id: str, label: str) -> str:
|
||
if root_id == "main-configuration":
|
||
return f"common.{label}"
|
||
return f"{root_id}.common.{label}"
|
||
|
||
|
||
def _slice_normalized_objects(objects, offset: int, limit: int) -> tuple[list[MetadataTreeNodeResponse], int]:
|
||
page = objects[offset : offset + limit]
|
||
return [_normalized_object_tree_node(item) for item in page], len(objects)
|
||
|
||
|
||
def _normalized_child_group_for_node(normalized: NormalizedProject, node_id: str):
|
||
prefix = "normalized."
|
||
if not node_id.startswith(prefix):
|
||
return None
|
||
payload = node_id.removeprefix(prefix)
|
||
if "." not in payload:
|
||
return None
|
||
object_hash, label = payload.split(".", 1)
|
||
item = next(
|
||
(
|
||
candidate
|
||
for group in _normalized_all_groups(normalized)
|
||
for candidate in group.objects
|
||
if stable_hash(candidate.qualified_name) == object_hash
|
||
),
|
||
None,
|
||
)
|
||
if item is None:
|
||
return None
|
||
group = _NORMALIZED_CHILD_GROUPS_BY_LABEL.get(label)
|
||
if group is None:
|
||
return None
|
||
field_name, icon = group
|
||
return getattr(item, field_name, []), icon
|
||
|
||
|
||
def _normalized_all_groups(normalized: NormalizedProject):
|
||
groups = list(normalized.configuration.groups)
|
||
for extension in normalized.configuration.extensions:
|
||
groups.extend(extension.groups)
|
||
return groups
|
||
|
||
|
||
_NORMALIZED_CHILD_GROUPS_BY_LABEL = {
|
||
"Реквизиты": ("attributes", "attribute"),
|
||
"Измерения": ("dimensions", "attribute"),
|
||
"Ресурсы": ("resources", "attribute"),
|
||
"Табличные части": ("tabular_sections", "table"),
|
||
"Формы": ("forms", "form"),
|
||
"Команды": ("commands", "command"),
|
||
"Макеты": ("layouts", "layout"),
|
||
"Табличные документы": ("tabular_documents", "table"),
|
||
"СКД": ("data_composition_schemas", "layout"),
|
||
"Варианты отчета": ("report_variants", "report"),
|
||
"Настройки": ("report_settings", "settings"),
|
||
"Хранилище вариантов": ("report_storages", "settings"),
|
||
"Хранилище настроек": ("report_storages", "settings"),
|
||
"Справка": ("help_items", "common"),
|
||
"Модули": ("modules", "module"),
|
||
"Модуль": ("modules", "module"),
|
||
"Модуль формы": ("modules", "module"),
|
||
"Модуль объекта": ("modules", "module"),
|
||
"Модуль менеджера": ("modules", "module"),
|
||
"Модуль набора записей": ("modules", "module"),
|
||
"Права": ("rights", "role"),
|
||
"События": ("events", "event"),
|
||
"Движения": ("movements", "movement"),
|
||
"Значения": ("values", "enum"),
|
||
"Предопределенные данные": ("predefined", "metadata"),
|
||
"Шаблоны URL": ("url_templates", "http"),
|
||
"Методы": ("methods", "module"),
|
||
"Метод": ("methods", "module"),
|
||
"Операции": ("operations", "service"),
|
||
"Параметры": ("parameters", "attribute"),
|
||
"Каналы": ("channels", "service"),
|
||
"Сообщения": ("messages", "service"),
|
||
"Таблицы": ("tables", "table"),
|
||
"Кубы": ("cubes", "table"),
|
||
"Функции": ("methods", "module"),
|
||
"Графы": ("fields", "attribute"),
|
||
"Элементы": ("fields", "attribute"),
|
||
}
|
||
|
||
|
||
def _normalized_part_tree_node(part, fallback_icon: str) -> MetadataTreeNodeResponse:
|
||
qualified_name = part.qualified_name or part.name
|
||
children = [_normalized_part_tree_node(child, _normalized_part_icon(child, fallback_icon)) for child in getattr(part, "children", [])]
|
||
part_key = f"{qualified_name}.{part.kind}.{part.source_path or ''}"
|
||
return _metadata_tree_node(
|
||
f"normalized.part.{stable_hash(part_key)}",
|
||
part.name,
|
||
part.kind,
|
||
_normalized_part_icon(part, fallback_icon),
|
||
qualified_name=qualified_name,
|
||
children=children,
|
||
count=len(children),
|
||
)
|
||
|
||
|
||
def _normalized_part_icon(part, fallback_icon: str) -> str:
|
||
kind = getattr(part, "kind", "").upper()
|
||
if kind == "FORM":
|
||
return "form"
|
||
if kind == "COMMAND":
|
||
return "command"
|
||
if kind == "MODULE":
|
||
return "module"
|
||
if kind in {"METHOD", "OPERATION"}:
|
||
return "module"
|
||
if kind in {"DATA_COMPOSITION_SCHEMA", "LAYOUT"}:
|
||
return "layout"
|
||
if kind in {"REPORT_VARIANT", "REPORT"}:
|
||
return "report"
|
||
if kind in {"REPORT_SETTING", "REPORT_STORAGE"}:
|
||
return "settings"
|
||
if kind == "HELP":
|
||
return "common"
|
||
if kind in {"URL_TEMPLATE", "HTTP_SERVICE"}:
|
||
return "http"
|
||
if kind in {"PARAMETER", "FIELD", "DIMENSION", "RESOURCE"}:
|
||
return "attribute"
|
||
if kind in {"ENUM_VALUE", "PREDEFINED"}:
|
||
return "enum"
|
||
if kind in {"CHANNEL", "MESSAGE"}:
|
||
return "service"
|
||
if kind in {"RIGHTS", "ROLE"}:
|
||
return "role"
|
||
if "TABULAR" in kind:
|
||
return "table"
|
||
if "EVENT" in kind:
|
||
return "event"
|
||
return fallback_icon
|
||
|
||
|
||
def _normalized_object_icon(object_kind: str) -> str:
|
||
kind = object_kind.upper()
|
||
if "EXCHANGE_PLAN" in kind or "ПЛАНОБМЕНА" in kind:
|
||
return "exchange-plan"
|
||
if "SCHEDULED_JOB" in kind or "РЕГЛАМЕНТНОЕЗАДАНИЕ" in kind:
|
||
return "scheduled-job"
|
||
if "EVENT_SUBSCRIPTION" in kind or "ПОДПИСКА" in kind:
|
||
return "event"
|
||
if "CATALOG" in kind:
|
||
return "catalog"
|
||
if "DOCUMENT" in kind:
|
||
return "document"
|
||
if "REGISTER" in kind:
|
||
return "register"
|
||
if "REPORT" in kind:
|
||
return "report"
|
||
if "PROCESSING" in kind:
|
||
return "processing"
|
||
if "FORM" in kind:
|
||
return "form"
|
||
if "COMMAND" in kind:
|
||
return "command"
|
||
if "TASK" in kind:
|
||
return "task"
|
||
if "ENUM" in kind:
|
||
return "enum"
|
||
if "COMMON MODULE" in kind or "COMMON_MODULE" in kind:
|
||
return "module"
|
||
if "EXTENSION" in kind:
|
||
return "extension"
|
||
return "metadata"
|
||
|
||
|
||
def _metadata_tree_branch_for_spec(snapshot: SirSnapshot, spec, object_limit_per_branch: int) -> MetadataTreeNodeResponse:
|
||
object_nodes = sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
visible_nodes = object_nodes[:object_limit_per_branch] if object_limit_per_branch > 0 else []
|
||
child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in visible_nodes])
|
||
children = [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in visible_nodes]
|
||
return _metadata_tree_node(
|
||
f"branch.{spec.code}",
|
||
spec.tree_branch,
|
||
spec.code,
|
||
spec.icon,
|
||
children=children,
|
||
count=len(object_nodes),
|
||
has_more=len(visible_nodes) < len(object_nodes),
|
||
)
|
||
|
||
|
||
def _metadata_object_tree_node(snapshot: SirSnapshot, node, spec, child_count_index: dict[str, Counter[EdgeKind]] | None = None) -> MetadataTreeNodeResponse:
|
||
groups = (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания")
|
||
return _metadata_tree_node(
|
||
node.lineage_id,
|
||
node.name,
|
||
node.kind.value,
|
||
spec.icon,
|
||
qualified_name=node.qualified_name,
|
||
children=[
|
||
_metadata_tree_node(
|
||
f"{node.lineage_id}.{group}",
|
||
group,
|
||
"METADATA_CHILD_GROUP",
|
||
_metadata_icon_for_child_group(group),
|
||
count=_metadata_child_group_count_from_index(child_count_index, node.lineage_id, group),
|
||
has_more=_metadata_child_group_count_from_index(child_count_index, node.lineage_id, group) > 0,
|
||
)
|
||
for group in groups
|
||
],
|
||
count=len(groups),
|
||
)
|
||
|
||
|
||
def _metadata_tree_children_for_node(
|
||
snapshot: SirSnapshot,
|
||
*,
|
||
node_id: str,
|
||
offset: int,
|
||
limit: int,
|
||
) -> tuple[list[MetadataTreeNodeResponse], int]:
|
||
if node_id.startswith("branch."):
|
||
spec_code = node_id.removeprefix("branch.")
|
||
spec = _metadata_spec_by_code(spec_code)
|
||
if spec is None:
|
||
raise HTTPException(status_code=404, detail=f"Metadata branch not found: {node_id}")
|
||
object_nodes = sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
page = object_nodes[offset : offset + limit]
|
||
child_count_index = _metadata_child_count_index(snapshot, [node.lineage_id for node in page])
|
||
return [_metadata_object_tree_node(snapshot, node, spec, child_count_index) for node in page], len(object_nodes)
|
||
|
||
if node_id == "common":
|
||
children = [
|
||
_metadata_common_branch(snapshot, label, 200)
|
||
for label in COMMON_BRANCH_CHILDREN
|
||
]
|
||
return _slice_metadata_children(children, offset, limit)
|
||
|
||
if node_id.startswith("common."):
|
||
return _common_branch_children(snapshot, node_id.removeprefix("common."), offset, limit)
|
||
|
||
group_owner_id, group_name = _split_metadata_group_node_id(node_id)
|
||
if group_owner_id and group_name:
|
||
return _metadata_child_group_children(snapshot, group_owner_id, group_name, offset, limit)
|
||
|
||
owner_node = _find_snapshot_node(snapshot, node_id)
|
||
if owner_node is not None:
|
||
spec = _metadata_spec_for_node(owner_node)
|
||
if spec is None:
|
||
return [], 0
|
||
children = [
|
||
_metadata_tree_node(
|
||
f"{owner_node.lineage_id}.{group}",
|
||
group,
|
||
"METADATA_CHILD_GROUP",
|
||
_metadata_icon_for_child_group(group),
|
||
count=_metadata_child_group_count(snapshot, owner_node.lineage_id, group),
|
||
has_more=_metadata_child_group_count(snapshot, owner_node.lineage_id, group) > 0,
|
||
)
|
||
for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания")
|
||
]
|
||
return _slice_metadata_children(children, offset, limit)
|
||
|
||
raise HTTPException(status_code=404, detail=f"Metadata tree node not found: {node_id}")
|
||
|
||
|
||
def _slice_metadata_children(
|
||
children: list[MetadataTreeNodeResponse],
|
||
offset: int,
|
||
limit: int,
|
||
) -> tuple[list[MetadataTreeNodeResponse], int]:
|
||
return children[offset : offset + limit], len(children)
|
||
|
||
|
||
def _common_branch_children(
|
||
snapshot: SirSnapshot,
|
||
label: str,
|
||
offset: int,
|
||
limit: int,
|
||
) -> tuple[list[MetadataTreeNodeResponse], int]:
|
||
spec_code = _common_branch_spec_code(label)
|
||
if spec_code is None:
|
||
return [], 0
|
||
spec = _metadata_spec_by_code(spec_code)
|
||
if spec is None:
|
||
return [], 0
|
||
object_nodes = sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
page = object_nodes[offset : offset + limit]
|
||
return [_metadata_object_tree_node(snapshot, node, spec) for node in page], len(object_nodes)
|
||
|
||
|
||
def _split_metadata_group_node_id(node_id: str) -> tuple[str | None, str | None]:
|
||
known_groups = {
|
||
group
|
||
for spec in METADATA_TYPE_SPECS
|
||
for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания")
|
||
}
|
||
for group in sorted(known_groups, key=len, reverse=True):
|
||
suffix = f".{group}"
|
||
if node_id.endswith(suffix):
|
||
return node_id[: -len(suffix)], group
|
||
return None, None
|
||
|
||
|
||
def _metadata_child_group_children(
|
||
snapshot: SirSnapshot,
|
||
owner_id: str,
|
||
group_name: str,
|
||
offset: int,
|
||
limit: int,
|
||
) -> tuple[list[MetadataTreeNodeResponse], int]:
|
||
edge_kinds = _edge_kinds_for_metadata_group(group_name)
|
||
if not edge_kinds:
|
||
return [], 0
|
||
target_ids = [
|
||
edge.target_lineage
|
||
for edge in snapshot.edges
|
||
if edge.source_lineage == owner_id
|
||
and edge.kind in edge_kinds
|
||
and (edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE")
|
||
]
|
||
nodes_by_id = {node.lineage_id: node for node in snapshot.nodes}
|
||
child_nodes = sorted(
|
||
[nodes_by_id[target_id] for target_id in target_ids if target_id in nodes_by_id],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
if group_name == "Процедуры":
|
||
child_nodes = [node for node in child_nodes if node.kind == NodeKind.PROCEDURE]
|
||
elif group_name == "Функции":
|
||
child_nodes = [node for node in child_nodes if node.kind == NodeKind.FUNCTION]
|
||
page = child_nodes[offset : offset + limit]
|
||
return [_metadata_leaf_tree_node(snapshot, node) for node in page], len(child_nodes)
|
||
|
||
|
||
def _edge_kinds_for_metadata_group(group_name: str) -> set[EdgeKind]:
|
||
lowered = group_name.lower()
|
||
if any(token in lowered for token in ("реквизит", "измерен", "ресурс", "граф", "значен")):
|
||
return {EdgeKind.HAS_ATTRIBUTE}
|
||
if "таблич" in lowered:
|
||
return {EdgeKind.HAS_TABULAR_SECTION}
|
||
if "форм" in lowered:
|
||
return {EdgeKind.HAS_FORM}
|
||
if "команд" in lowered:
|
||
return {EdgeKind.HAS_COMMAND}
|
||
if "права" in lowered:
|
||
return {EdgeKind.HAS_ROLE}
|
||
if "модул" in lowered:
|
||
return {EdgeKind.CONTAINS}
|
||
if "процедур" in lowered or "функц" in lowered:
|
||
return {EdgeKind.DECLARES}
|
||
return set()
|
||
|
||
|
||
def _metadata_leaf_tree_node(snapshot: SirSnapshot, node) -> MetadataTreeNodeResponse:
|
||
child_groups = []
|
||
if node.kind == NodeKind.TABULAR_SECTION:
|
||
child_groups.append("Реквизиты")
|
||
if node.kind == NodeKind.FORM:
|
||
child_groups.extend(["Элементы", "Команды", "События", "Модуль формы"])
|
||
if node.kind == NodeKind.MODULE:
|
||
child_groups.extend(["Процедуры", "Функции"])
|
||
return _metadata_tree_node(
|
||
node.lineage_id,
|
||
node.name,
|
||
node.kind.value,
|
||
_metadata_icon_for_node_kind(node.kind),
|
||
qualified_name=node.qualified_name,
|
||
children=[
|
||
_metadata_tree_node(
|
||
f"{node.lineage_id}.{group}",
|
||
group,
|
||
"METADATA_CHILD_GROUP",
|
||
_metadata_icon_for_child_group(group),
|
||
has_more=True,
|
||
)
|
||
for group in child_groups
|
||
],
|
||
count=len(child_groups),
|
||
)
|
||
|
||
|
||
def _metadata_node_for_search_result(
|
||
snapshot: SirSnapshot,
|
||
node,
|
||
child_count_index: dict[str, Counter[EdgeKind]] | None = None,
|
||
) -> MetadataTreeNodeResponse:
|
||
spec = _metadata_spec_for_node(node)
|
||
if spec:
|
||
return _metadata_object_tree_node(snapshot, node, spec, child_count_index)
|
||
return _metadata_leaf_tree_node(snapshot, node)
|
||
|
||
|
||
_FLOWCHART_KIND_LABELS = {
|
||
NodeKind.PROJECT: "Проект",
|
||
NodeKind.CATALOG: "Справочники",
|
||
NodeKind.DOCUMENT: "Документы",
|
||
NodeKind.COMMON_MODULE: "Общие модули",
|
||
NodeKind.MODULE: "Модули BSL",
|
||
NodeKind.PROCEDURE: "Процедуры",
|
||
NodeKind.FUNCTION: "Функции",
|
||
NodeKind.FORM: "Формы",
|
||
NodeKind.COMMAND: "Команды",
|
||
NodeKind.ROLE: "Роли",
|
||
NodeKind.ATTRIBUTE: "Реквизиты",
|
||
NodeKind.TABULAR_SECTION: "Табличные части",
|
||
NodeKind.QUERY: "Запросы",
|
||
NodeKind.TABLE: "Таблицы",
|
||
NodeKind.REGISTER: "Регистры",
|
||
NodeKind.REPORT: "Отчеты",
|
||
NodeKind.DATA_PROCESSOR: "Обработки",
|
||
NodeKind.HTTP_SERVICE: "HTTP-сервисы",
|
||
NodeKind.INTEGRATION_ENDPOINT: "Интеграции",
|
||
NodeKind.SCHEDULED_JOB: "Регламентные задания",
|
||
NodeKind.EXTENSION: "Расширения",
|
||
}
|
||
|
||
_FLOWCHART_EDGE_LABELS = {
|
||
EdgeKind.CONTAINS: "содержит",
|
||
EdgeKind.DECLARES: "объявляет",
|
||
EdgeKind.CALLS: "вызывает",
|
||
EdgeKind.OWNS_QUERY: "запрос",
|
||
EdgeKind.READS_TABLE: "читает",
|
||
EdgeKind.WRITES: "записывает",
|
||
EdgeKind.HAS_FORM: "форма",
|
||
EdgeKind.HAS_COMMAND: "команда",
|
||
EdgeKind.HAS_ROLE: "роль",
|
||
EdgeKind.HAS_ATTRIBUTE: "реквизит",
|
||
EdgeKind.HAS_TABULAR_SECTION: "табличная часть",
|
||
EdgeKind.HAS_ELEMENT: "элемент",
|
||
EdgeKind.GRANTS_ACCESS: "права",
|
||
EdgeKind.RUNS: "запускает",
|
||
EdgeKind.USES_INTEGRATION: "интеграция",
|
||
EdgeKind.HANDLES: "обработчик",
|
||
}
|
||
|
||
_FLOWCHART_IMPORTANT_EDGES = {
|
||
EdgeKind.CALLS,
|
||
EdgeKind.OWNS_QUERY,
|
||
EdgeKind.READS_TABLE,
|
||
EdgeKind.WRITES,
|
||
EdgeKind.HAS_ROLE,
|
||
EdgeKind.GRANTS_ACCESS,
|
||
EdgeKind.RUNS,
|
||
EdgeKind.USES_INTEGRATION,
|
||
EdgeKind.HANDLES,
|
||
}
|
||
|
||
_FLOWCHART_LOGIC_NODE_KINDS = {
|
||
NodeKind.CATALOG,
|
||
NodeKind.DOCUMENT,
|
||
NodeKind.REGISTER,
|
||
NodeKind.CONSTANT,
|
||
NodeKind.DOCUMENT_JOURNAL,
|
||
NodeKind.ENUM,
|
||
NodeKind.REPORT,
|
||
NodeKind.DATA_PROCESSOR,
|
||
NodeKind.CHART_OF_CHARACTERISTIC_TYPES,
|
||
NodeKind.CHART_OF_ACCOUNTS,
|
||
NodeKind.CHART_OF_CALCULATION_TYPES,
|
||
NodeKind.COMMON_MODULE,
|
||
NodeKind.EXCHANGE_PLAN,
|
||
NodeKind.EXTERNAL_DATA_SOURCE,
|
||
NodeKind.SCHEDULED_JOB,
|
||
NodeKind.BUSINESS_PROCESS,
|
||
NodeKind.TASK,
|
||
NodeKind.SUBSYSTEM,
|
||
NodeKind.HTTP_SERVICE,
|
||
NodeKind.XDTO_PACKAGE,
|
||
NodeKind.EXTENSION,
|
||
NodeKind.INTEGRATION_ENDPOINT,
|
||
NodeKind.ROLE,
|
||
}
|
||
|
||
_FLOWCHART_DETAIL_NODE_KINDS = _FLOWCHART_LOGIC_NODE_KINDS | {
|
||
NodeKind.MODULE,
|
||
NodeKind.PROCEDURE,
|
||
NodeKind.FUNCTION,
|
||
NodeKind.QUERY,
|
||
NodeKind.TABLE,
|
||
}
|
||
|
||
|
||
def _flowchart_overview(snapshot: SirSnapshot, limit: int) -> ProjectFlowchartResponse:
|
||
kind_counts = Counter(node.kind for node in snapshot.nodes if node.kind in _FLOWCHART_LOGIC_NODE_KINDS)
|
||
kind_nodes = [
|
||
FlowchartNodeResponse(
|
||
id=f"kind:{kind.value}",
|
||
label=_FLOWCHART_KIND_LABELS.get(kind, kind.value),
|
||
kind=kind.value,
|
||
count=count,
|
||
expandable=count > 0,
|
||
)
|
||
for kind, count in kind_counts.most_common(limit)
|
||
if count > 0
|
||
]
|
||
available_kind_ids = {node.id for node in kind_nodes}
|
||
logic_edges = _flowchart_logical_edges(snapshot)
|
||
edge_counts: Counter[tuple[NodeKind, NodeKind, EdgeKind]] = Counter()
|
||
for source, target, edge_kind, _label in logic_edges:
|
||
source_id = f"kind:{source.kind.value}"
|
||
target_id = f"kind:{target.kind.value}"
|
||
if source_id not in available_kind_ids or target_id not in available_kind_ids or source_id == target_id:
|
||
continue
|
||
edge_counts[(source.kind, target.kind, edge_kind)] += 1
|
||
flow_edges = [
|
||
FlowchartEdgeResponse(
|
||
id=f"kind:{source.value}:{edge_kind.value}:{target.value}",
|
||
source=f"kind:{source.value}",
|
||
target=f"kind:{target.value}",
|
||
kind=edge_kind.value,
|
||
label=_FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value),
|
||
count=count,
|
||
)
|
||
for (source, target, edge_kind), count in edge_counts.most_common(limit * 2)
|
||
]
|
||
return ProjectFlowchartResponse(
|
||
project_id=snapshot.project_id,
|
||
mode="overview",
|
||
total_nodes=len(snapshot.nodes),
|
||
total_edges=len(snapshot.edges),
|
||
nodes=kind_nodes,
|
||
edges=flow_edges,
|
||
)
|
||
|
||
|
||
def _flowchart_for_focus(
|
||
snapshot: SirSnapshot,
|
||
focus: str,
|
||
depth: int,
|
||
limit: int,
|
||
) -> ProjectFlowchartResponse | None:
|
||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||
normalized_focus = focus.strip().casefold()
|
||
focus_node = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.lineage_id == focus
|
||
or node.qualified_name.casefold() == normalized_focus
|
||
or node.name.casefold() == normalized_focus
|
||
),
|
||
None,
|
||
)
|
||
if focus_node is None:
|
||
return None
|
||
|
||
logic_edges = _flowchart_logical_edges(snapshot)
|
||
adjacency: dict[str, list[tuple[str, EdgeKind, str]]] = {}
|
||
for source, target, edge_kind, label in logic_edges:
|
||
if source.lineage_id == target.lineage_id:
|
||
continue
|
||
adjacency.setdefault(source.lineage_id, []).append((target.lineage_id, edge_kind, label))
|
||
adjacency.setdefault(target.lineage_id, []).append((source.lineage_id, edge_kind, label))
|
||
|
||
selected_ids: set[str] = {focus_node.lineage_id}
|
||
levels: dict[str, int] = {focus_node.lineage_id: 0}
|
||
frontier = [focus_node.lineage_id]
|
||
for level in range(1, depth + 1):
|
||
next_frontier: list[str] = []
|
||
for lineage_id in frontier:
|
||
for neighbor_id, _edge_kind, _label in adjacency.get(lineage_id, []):
|
||
if neighbor_id in selected_ids or len(selected_ids) >= limit:
|
||
continue
|
||
selected_ids.add(neighbor_id)
|
||
levels[neighbor_id] = level
|
||
next_frontier.append(neighbor_id)
|
||
frontier = next_frontier
|
||
if not frontier or len(selected_ids) >= limit:
|
||
break
|
||
|
||
flow_nodes = [
|
||
FlowchartNodeResponse(
|
||
id=node.lineage_id,
|
||
label=node.name,
|
||
kind=node.kind.value,
|
||
qualified_name=node.qualified_name,
|
||
level=levels.get(node.lineage_id, depth),
|
||
expandable=bool(adjacency.get(node.lineage_id)),
|
||
)
|
||
for node in sorted(
|
||
(nodes_by_lineage[lineage_id] for lineage_id in selected_ids if nodes_by_lineage[lineage_id].kind in _FLOWCHART_DETAIL_NODE_KINDS),
|
||
key=lambda item: (levels.get(item.lineage_id, depth), item.kind.value, item.qualified_name),
|
||
)
|
||
]
|
||
flow_edges = [
|
||
FlowchartEdgeResponse(
|
||
id=f"logic:{source.lineage_id}:{edge_kind.value}:{target.lineage_id}:{index}",
|
||
source=source.lineage_id,
|
||
target=target.lineage_id,
|
||
kind=edge_kind.value,
|
||
label=label or _FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value),
|
||
)
|
||
for index, (source, target, edge_kind, label) in enumerate(logic_edges)
|
||
if source.lineage_id in selected_ids
|
||
and target.lineage_id in selected_ids
|
||
][: limit * 3]
|
||
return ProjectFlowchartResponse(
|
||
project_id=snapshot.project_id,
|
||
mode="focus",
|
||
focus=focus_node.lineage_id,
|
||
total_nodes=len(snapshot.nodes),
|
||
total_edges=len(snapshot.edges),
|
||
nodes=flow_nodes,
|
||
edges=flow_edges,
|
||
)
|
||
|
||
|
||
def _flowchart_logical_edges(snapshot: SirSnapshot) -> list[tuple[object, object, EdgeKind, str]]:
|
||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||
metadata_by_qname = {
|
||
node.qualified_name: node
|
||
for node in snapshot.nodes
|
||
if node.kind in _FLOWCHART_LOGIC_NODE_KINDS
|
||
}
|
||
owner_by_lineage = _flowchart_owner_index(snapshot, nodes_by_lineage)
|
||
result: list[tuple[object, object, EdgeKind, str]] = []
|
||
seen: set[tuple[str, str, EdgeKind, str]] = set()
|
||
for edge in snapshot.edges:
|
||
if edge.kind not in _FLOWCHART_IMPORTANT_EDGES:
|
||
continue
|
||
source = nodes_by_lineage.get(edge.source_lineage)
|
||
target = nodes_by_lineage.get(edge.target_lineage)
|
||
if source is None or target is None:
|
||
continue
|
||
logical_source = _flowchart_logical_node(source, owner_by_lineage, metadata_by_qname)
|
||
logical_target = _flowchart_logical_node(target, owner_by_lineage, metadata_by_qname)
|
||
if logical_source is None or logical_target is None or logical_source.lineage_id == logical_target.lineage_id:
|
||
continue
|
||
label = _flowchart_logical_edge_label(edge.kind, source, target, logical_source, logical_target)
|
||
key = (logical_source.lineage_id, logical_target.lineage_id, edge.kind, label)
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
result.append((logical_source, logical_target, edge.kind, label))
|
||
return result
|
||
|
||
|
||
def _flowchart_owner_index(snapshot: SirSnapshot, nodes_by_lineage: dict[str, object]) -> dict[str, object]:
|
||
owner_by_lineage: dict[str, object] = {}
|
||
for edge in snapshot.edges:
|
||
source = nodes_by_lineage.get(edge.source_lineage)
|
||
target = nodes_by_lineage.get(edge.target_lineage)
|
||
if source is None or target is None:
|
||
continue
|
||
if edge.kind == EdgeKind.CONTAINS and edge.attributes.get("link_type") == "METADATA_MODULE":
|
||
owner_by_lineage[target.lineage_id] = source
|
||
elif edge.kind in {EdgeKind.HAS_COMMAND, EdgeKind.HAS_ROLE, EdgeKind.HAS_ATTRIBUTE, EdgeKind.HAS_TABULAR_SECTION, EdgeKind.HAS_FORM, EdgeKind.HAS_ELEMENT}:
|
||
owner_by_lineage[target.lineage_id] = source
|
||
for edge in snapshot.edges:
|
||
source = nodes_by_lineage.get(edge.source_lineage)
|
||
target = nodes_by_lineage.get(edge.target_lineage)
|
||
if source is None or target is None:
|
||
continue
|
||
if edge.kind in {EdgeKind.DECLARES, EdgeKind.OWNS_QUERY}:
|
||
owner_by_lineage[target.lineage_id] = owner_by_lineage.get(source.lineage_id, source)
|
||
return owner_by_lineage
|
||
|
||
|
||
def _flowchart_logical_node(node, owner_by_lineage: dict[str, object], metadata_by_qname: dict[str, object]):
|
||
if node.kind in _FLOWCHART_LOGIC_NODE_KINDS:
|
||
return node
|
||
owner = owner_by_lineage.get(node.lineage_id)
|
||
if owner is not None and owner.kind in _FLOWCHART_LOGIC_NODE_KINDS:
|
||
return owner
|
||
if node.kind == NodeKind.TABLE:
|
||
return metadata_by_qname.get(node.qualified_name) or metadata_by_qname.get(node.name)
|
||
return None
|
||
|
||
|
||
def _flowchart_logical_edge_label(edge_kind: EdgeKind, source, target, logical_source, logical_target) -> str:
|
||
base = _FLOWCHART_EDGE_LABELS.get(edge_kind, edge_kind.value)
|
||
if edge_kind == EdgeKind.CALLS and source.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}:
|
||
return f"{base}: {source.name}"
|
||
if edge_kind in {EdgeKind.READS_TABLE, EdgeKind.WRITES}:
|
||
return base
|
||
if target.kind in {NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION}:
|
||
return f"{base}: {target.name}"
|
||
if source.lineage_id != logical_source.lineage_id and source.name:
|
||
return f"{base}: {source.name}"
|
||
if target.lineage_id != logical_target.lineage_id and target.name:
|
||
return f"{base}: {target.name}"
|
||
return base
|
||
|
||
|
||
def _metadata_tree_path_for_node(snapshot: SirSnapshot, node_id: str) -> list[str]:
|
||
node = _find_snapshot_node(snapshot, node_id)
|
||
if node is None:
|
||
group_owner_id, group_name = _split_metadata_group_node_id(node_id)
|
||
if group_owner_id and group_name:
|
||
owner_node = _find_snapshot_node(snapshot, group_owner_id)
|
||
if owner_node is None:
|
||
return []
|
||
return [*_metadata_tree_path_for_node(snapshot, group_owner_id), node_id]
|
||
return []
|
||
|
||
spec = _metadata_spec_for_node(node)
|
||
if spec is not None:
|
||
common_branch = _common_branch_label_for_spec(spec.code)
|
||
if common_branch:
|
||
return ["main-configuration", "common", f"common.{common_branch}", node.lineage_id]
|
||
return ["main-configuration", f"branch.{spec.code}", node.lineage_id]
|
||
|
||
parent_edge = next((edge for edge in snapshot.edges if edge.target_lineage == node.lineage_id), None)
|
||
if parent_edge is None:
|
||
return [node.lineage_id]
|
||
owner_node = _find_snapshot_node(snapshot, parent_edge.source_lineage)
|
||
if owner_node is None:
|
||
return [node.lineage_id]
|
||
group_name = _metadata_group_name_for_edge(parent_edge.kind)
|
||
return [*_metadata_tree_path_for_node(snapshot, owner_node.lineage_id), f"{owner_node.lineage_id}.{group_name}", node.lineage_id]
|
||
|
||
|
||
def _metadata_tree_path_steps(snapshot: SirSnapshot, path: list[str]) -> list[MetadataTreePathStepResponse]:
|
||
return [
|
||
MetadataTreePathStepResponse(
|
||
parent_id=parent_id,
|
||
child_id=child_id,
|
||
offset=_metadata_tree_child_offset(snapshot, parent_id, child_id),
|
||
)
|
||
for parent_id, child_id in zip(path, path[1:])
|
||
]
|
||
|
||
|
||
def _metadata_tree_child_offset(snapshot: SirSnapshot, parent_id: str, child_id: str) -> int:
|
||
children = _metadata_tree_child_ids(snapshot, parent_id)
|
||
try:
|
||
return children.index(child_id)
|
||
except ValueError:
|
||
return 0
|
||
|
||
|
||
def _metadata_tree_child_ids(snapshot: SirSnapshot, parent_id: str) -> list[str]:
|
||
if parent_id == "main-configuration":
|
||
return [
|
||
"main-configuration.info",
|
||
"common",
|
||
*[
|
||
f"branch.{spec.code}"
|
||
for spec in METADATA_TYPE_SPECS
|
||
if spec.code not in {"COMMON", "EXTENSION"} and spec.tree_branch not in COMMON_BRANCH_CHILDREN
|
||
],
|
||
]
|
||
if parent_id == "common":
|
||
return [f"common.{label}" for label in COMMON_BRANCH_CHILDREN]
|
||
if parent_id.startswith("branch."):
|
||
spec_code = parent_id.removeprefix("branch.")
|
||
spec = _metadata_spec_by_code(spec_code)
|
||
if spec is None:
|
||
return []
|
||
return [
|
||
node.lineage_id
|
||
for node in sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
]
|
||
if parent_id.startswith("common."):
|
||
spec_code = _common_branch_spec_code(parent_id.removeprefix("common."))
|
||
spec = _metadata_spec_by_code(spec_code)
|
||
if spec is None:
|
||
return []
|
||
return [
|
||
node.lineage_id
|
||
for node in sorted(
|
||
[node for node in snapshot.nodes if _node_matches_metadata_spec(node, spec)],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
]
|
||
|
||
group_owner_id, group_name = _split_metadata_group_node_id(parent_id)
|
||
if group_owner_id and group_name:
|
||
return _metadata_child_group_child_ids(snapshot, group_owner_id, group_name)
|
||
|
||
owner_node = _find_snapshot_node(snapshot, parent_id)
|
||
if owner_node is None:
|
||
return []
|
||
spec = _metadata_spec_for_node(owner_node)
|
||
if spec is None:
|
||
return []
|
||
return [
|
||
f"{owner_node.lineage_id}.{group}"
|
||
for group in (*spec.child_groups, *spec.module_kinds, "Версии", "Проверки", "Инциденты", "Знания")
|
||
]
|
||
|
||
|
||
def _metadata_child_group_child_ids(snapshot: SirSnapshot, owner_id: str, group_name: str) -> list[str]:
|
||
edge_kinds = _edge_kinds_for_metadata_group(group_name)
|
||
if not edge_kinds:
|
||
return []
|
||
target_ids = [
|
||
edge.target_lineage
|
||
for edge in snapshot.edges
|
||
if edge.source_lineage == owner_id and edge.kind in edge_kinds
|
||
]
|
||
nodes_by_id = {node.lineage_id: node for node in snapshot.nodes}
|
||
return [
|
||
node.lineage_id
|
||
for node in sorted(
|
||
[nodes_by_id[target_id] for target_id in target_ids if target_id in nodes_by_id],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
]
|
||
|
||
|
||
def _common_branch_label_for_spec(spec_code: str) -> str | None:
|
||
if spec_code in {"COMMON", "EXTENSION"}:
|
||
return None
|
||
spec = METADATA_TYPE_BY_CODE.get(spec_code)
|
||
if spec is not None and spec.tree_branch in COMMON_BRANCH_CHILDREN:
|
||
return spec.tree_branch
|
||
for label in COMMON_BRANCH_CHILDREN:
|
||
branch = METADATA_TYPE_BY_BRANCH.get(label)
|
||
if branch is not None and branch.code == spec_code and branch.code not in {"COMMON", "EXTENSION"}:
|
||
return label
|
||
return None
|
||
|
||
|
||
def _metadata_group_name_for_edge(edge_kind: EdgeKind) -> str:
|
||
return {
|
||
EdgeKind.HAS_ATTRIBUTE: "Реквизиты",
|
||
EdgeKind.HAS_TABULAR_SECTION: "Табличные части",
|
||
EdgeKind.HAS_FORM: "Формы",
|
||
EdgeKind.HAS_COMMAND: "Команды",
|
||
EdgeKind.HAS_ROLE: "Права",
|
||
EdgeKind.CONTAINS: "Модуль",
|
||
EdgeKind.DECLARES: "Процедуры",
|
||
}.get(edge_kind, "Связи")
|
||
|
||
|
||
def _is_metadata_tree_search_node(node) -> bool:
|
||
return _is_top_level_metadata_node(node) or node.kind in {
|
||
NodeKind.ATTRIBUTE,
|
||
NodeKind.COMMAND,
|
||
NodeKind.FORM,
|
||
NodeKind.FORM_ELEMENT,
|
||
NodeKind.MODULE,
|
||
NodeKind.PROCEDURE,
|
||
NodeKind.FUNCTION,
|
||
NodeKind.ROLE,
|
||
NodeKind.TABULAR_SECTION,
|
||
}
|
||
|
||
|
||
def _metadata_search_rank(node, normalized_query: str) -> tuple[int, int, str]:
|
||
name = node.name.casefold()
|
||
qualified = node.qualified_name.casefold()
|
||
if name == normalized_query:
|
||
relevance = 0
|
||
elif name.startswith(normalized_query):
|
||
relevance = 1
|
||
elif normalized_query in name:
|
||
relevance = 2
|
||
elif qualified.startswith(normalized_query):
|
||
relevance = 3
|
||
else:
|
||
relevance = 4
|
||
depth = 0 if _is_top_level_metadata_node(node) else 1
|
||
return relevance, depth, node.qualified_name
|
||
|
||
|
||
def _metadata_child_group_count(snapshot: SirSnapshot, owner_id: str, group_name: str) -> int:
|
||
edge_kinds = _edge_kinds_for_metadata_group(group_name)
|
||
if not edge_kinds:
|
||
return 0
|
||
return sum(
|
||
1
|
||
for edge in snapshot.edges
|
||
if edge.source_lineage == owner_id
|
||
and edge.kind in edge_kinds
|
||
and (edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE")
|
||
)
|
||
|
||
|
||
def _metadata_child_group_count_from_index(
|
||
child_count_index: dict[str, Counter[EdgeKind]] | None,
|
||
owner_id: str,
|
||
group_name: str,
|
||
) -> int:
|
||
edge_kinds = _edge_kinds_for_metadata_group(group_name)
|
||
if not edge_kinds or child_count_index is None:
|
||
return 0
|
||
counts = child_count_index.get(owner_id)
|
||
if counts is None:
|
||
return 0
|
||
return sum(counts[kind] for kind in edge_kinds)
|
||
|
||
|
||
def _metadata_child_count_index(snapshot: SirSnapshot, owner_ids: list[str]) -> dict[str, Counter[EdgeKind]]:
|
||
owner_set = set(owner_ids)
|
||
result: dict[str, Counter[EdgeKind]] = {owner_id: Counter() for owner_id in owner_set}
|
||
for edge in snapshot.edges:
|
||
if edge.source_lineage in owner_set and (
|
||
edge.kind != EdgeKind.CONTAINS or edge.attributes.get("link_type") == "METADATA_MODULE"
|
||
):
|
||
result[edge.source_lineage][edge.kind] += 1
|
||
return result
|
||
|
||
|
||
def _find_snapshot_node(snapshot: SirSnapshot, lineage_id: str):
|
||
return next((node for node in snapshot.nodes if node.lineage_id == lineage_id), None)
|
||
|
||
|
||
def _metadata_spec_for_node(node):
|
||
for spec in METADATA_TYPE_SPECS:
|
||
if _node_matches_metadata_spec(node, spec):
|
||
return spec
|
||
return None
|
||
|
||
|
||
def _metadata_icon_for_node_kind(kind: NodeKind) -> str:
|
||
return _METADATA_ICON_BY_NODE_KIND.get(kind, "folder")
|
||
|
||
|
||
def _node_matches_metadata_spec(node, spec) -> bool:
|
||
if not _is_top_level_metadata_node(node):
|
||
return False
|
||
allowed_kinds = _METADATA_SPEC_NODE_KINDS.get(spec.code)
|
||
if allowed_kinds is not None and node.kind not in allowed_kinds:
|
||
return False
|
||
prefix = _METADATA_SPEC_PREFIXES.get(spec.code)
|
||
return bool(prefix and node.qualified_name.casefold().startswith(prefix.casefold()))
|
||
|
||
|
||
def _is_top_level_metadata_node(node) -> bool:
|
||
return node.qualified_name.count(".") == 1
|
||
|
||
|
||
def _metadata_icon_for_common_branch(label: str) -> str:
|
||
lowered = label.lower()
|
||
if "планы обмена" in lowered or "план обмена" in lowered:
|
||
return "exchange-plan"
|
||
if "подписки на события" in lowered or "подписка на событие" in lowered:
|
||
return "event"
|
||
if "регламентные задания" in lowered or "регламентное задание" in lowered:
|
||
return "scheduled-job"
|
||
if "модул" in label.lower():
|
||
return "module"
|
||
if "форм" in label.lower():
|
||
return "form"
|
||
if "команд" in label.lower():
|
||
return "command"
|
||
if "web" in label.lower() or "http" in label.lower() or "сервис" in label.lower():
|
||
return "web"
|
||
if "роль" in label.lower():
|
||
return "role"
|
||
return "folder"
|
||
|
||
|
||
def _metadata_icon_for_child_group(label: str) -> str:
|
||
lowered = label.lower()
|
||
if "реквизит" in lowered or "измерен" in lowered or "ресурс" in lowered:
|
||
return "attribute"
|
||
if "таблич" in lowered:
|
||
return "table"
|
||
if "форм" in lowered:
|
||
return "form"
|
||
if "команд" in lowered:
|
||
return "command"
|
||
if "модуль" in lowered:
|
||
return "module"
|
||
if "права" in lowered:
|
||
return "role"
|
||
return "folder"
|
||
|
||
|
||
_METADATA_OBJECT_KIND_PREFIXES = {
|
||
"COMMON_MODULE": "ОбщийМодуль",
|
||
"COMMON MODULE": "ОбщийМодуль",
|
||
"ОБЩИЙ МОДУЛЬ": "ОбщийМодуль",
|
||
"DOCUMENT": "Документ",
|
||
"ДОКУМЕНТ": "Документ",
|
||
"CATALOG": "Справочник",
|
||
"СПРАВОЧНИК": "Справочник",
|
||
"CONSTANT": "Константа",
|
||
"КОНСТАНТА": "Константа",
|
||
"DOCUMENT_JOURNAL": "ЖурналДокументов",
|
||
"DOCUMENT JOURNAL": "ЖурналДокументов",
|
||
"ЖУРНАЛ ДОКУМЕНТОВ": "ЖурналДокументов",
|
||
"ENUM": "Перечисление",
|
||
"ENUMERATION": "Перечисление",
|
||
"ПЕРЕЧИСЛЕНИЕ": "Перечисление",
|
||
"REPORT": "Отчет",
|
||
"ОТЧЕТ": "Отчет",
|
||
"DATA_PROCESSOR": "Обработка",
|
||
"DATA PROCESSOR": "Обработка",
|
||
"PROCESSING": "Обработка",
|
||
"ОБРАБОТКА": "Обработка",
|
||
"CHART_OF_CHARACTERISTIC_TYPES": "ПланВидовХарактеристик",
|
||
"CHART OF CHARACTERISTIC TYPES": "ПланВидовХарактеристик",
|
||
"ПЛАН ВИДОВ ХАРАКТЕРИСТИК": "ПланВидовХарактеристик",
|
||
"CHART_OF_ACCOUNTS": "ПланСчетов",
|
||
"CHART OF ACCOUNTS": "ПланСчетов",
|
||
"ПЛАН СЧЕТОВ": "ПланСчетов",
|
||
"CHART_OF_CALCULATION_TYPES": "ПланВидовРасчета",
|
||
"CHART OF CALCULATION TYPES": "ПланВидовРасчета",
|
||
"ПЛАН ВИДОВ РАСЧЕТА": "ПланВидовРасчета",
|
||
"INFORMATION_REGISTER": "РегистрСведений",
|
||
"INFORMATION REGISTER": "РегистрСведений",
|
||
"РЕГИСТР СВЕДЕНИЙ": "РегистрСведений",
|
||
"REGISTER": "РегистрНакопления",
|
||
"ACCUMULATION_REGISTER": "РегистрНакопления",
|
||
"ACCUMULATION REGISTER": "РегистрНакопления",
|
||
"РЕГИСТР": "РегистрНакопления",
|
||
"РЕГИСТР НАКОПЛЕНИЯ": "РегистрНакопления",
|
||
"ACCOUNTING_REGISTER": "РегистрБухгалтерии",
|
||
"ACCOUNTING REGISTER": "РегистрБухгалтерии",
|
||
"РЕГИСТР БУХГАЛТЕРИИ": "РегистрБухгалтерии",
|
||
"CALCULATION_REGISTER": "РегистрРасчета",
|
||
"CALCULATION REGISTER": "РегистрРасчета",
|
||
"РЕГИСТР РАСЧЕТА": "РегистрРасчета",
|
||
"BUSINESS_PROCESS": "БизнесПроцесс",
|
||
"BUSINESS PROCESS": "БизнесПроцесс",
|
||
"БИЗНЕС-ПРОЦЕСС": "БизнесПроцесс",
|
||
"БИЗНЕС ПРОЦЕСС": "БизнесПроцесс",
|
||
"TASK": "Задача",
|
||
"ЗАДАЧА": "Задача",
|
||
"1C_TASK": "Задача",
|
||
"EXTERNAL_DATA_SOURCE": "ВнешнийИсточникДанных",
|
||
"EXTERNAL DATA SOURCE": "ВнешнийИсточникДанных",
|
||
"ВНЕШНИЙ ИСТОЧНИК ДАННЫХ": "ВнешнийИсточникДанных",
|
||
}
|
||
|
||
|
||
_ONE_C_IDENTIFIER_RE = re.compile(r"^[A-Za-zА-Яа-яЁё_][A-Za-zА-Яа-яЁё0-9_]*$")
|
||
|
||
|
||
def _validate_one_c_identifier(name: str, field_name: str) -> str:
|
||
value = name.strip()
|
||
if not value:
|
||
raise HTTPException(status_code=422, detail=f"{field_name} is required")
|
||
if not _ONE_C_IDENTIFIER_RE.match(value):
|
||
raise HTTPException(status_code=422, detail=f"{field_name} must be a valid 1C identifier: {name}")
|
||
return value
|
||
|
||
|
||
def _validate_unique_names(items: list[str], field_name: str) -> None:
|
||
seen: set[str] = set()
|
||
for item in items:
|
||
normalized = item.strip().lower()
|
||
if normalized in seen:
|
||
raise HTTPException(status_code=422, detail=f"Duplicate {field_name}: {item}")
|
||
seen.add(normalized)
|
||
|
||
|
||
def _validate_metadata_object_draft(request: AuthoringMetadataObjectPreviewRequest) -> None:
|
||
_validate_one_c_identifier(request.name, "metadata object name")
|
||
attribute_names = [_validate_one_c_identifier(attribute.name, "attribute name") for attribute in request.attributes]
|
||
_validate_unique_names(attribute_names, "attribute name")
|
||
section_names = [_validate_one_c_identifier(section.name, "tabular section name") for section in request.tabular_sections]
|
||
_validate_unique_names(section_names, "tabular section name")
|
||
for section in request.tabular_sections:
|
||
column_names = [_validate_one_c_identifier(attribute.name, f"{section.name} column name") for attribute in section.attributes]
|
||
_validate_unique_names(column_names, f"{section.name} column name")
|
||
form_names = [_validate_one_c_identifier(form, "form name") for form in request.forms]
|
||
_validate_unique_names(form_names, "form name")
|
||
command_names = [_validate_one_c_identifier(command.name, "command name") for command in request.commands]
|
||
_validate_unique_names(command_names, "command name")
|
||
for command in request.commands:
|
||
if command.handler:
|
||
_validate_one_c_identifier(command.handler, f"{command.name} command handler")
|
||
|
||
|
||
def _metadata_object_kind_prefix(kind: str) -> str:
|
||
normalized = kind.strip().replace("_", " ").upper()
|
||
if normalized in _METADATA_OBJECT_KIND_PREFIXES:
|
||
return _METADATA_OBJECT_KIND_PREFIXES[normalized]
|
||
normalized = kind.strip().upper()
|
||
if normalized in _METADATA_OBJECT_KIND_PREFIXES:
|
||
return _METADATA_OBJECT_KIND_PREFIXES[normalized]
|
||
raise HTTPException(status_code=422, detail=f"Unsupported 1C metadata object kind: {kind}")
|
||
|
||
|
||
def _metadata_object_target(request: AuthoringMetadataObjectPreviewRequest) -> NamedNode:
|
||
prefix = _metadata_object_kind_prefix(request.object_kind)
|
||
qualified_name = f"{prefix}.{request.name}"
|
||
object_hash = stable_hash(
|
||
json.dumps(
|
||
{
|
||
"kind": prefix,
|
||
"name": request.name,
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
)
|
||
return NamedNode(
|
||
lineage_id=f"metadata.{prefix}.{request.name}",
|
||
kind="METADATA_OBJECT",
|
||
name=request.name,
|
||
qualified_name=qualified_name,
|
||
)
|
||
|
||
|
||
def _metadata_object_diff_lines(request: AuthoringMetadataObjectPreviewRequest) -> list[AuthoringDiffLine]:
|
||
prefix = _metadata_object_kind_prefix(request.object_kind)
|
||
diff = [AuthoringDiffLine(kind="ADD", text=f"{prefix}.{request.name}")]
|
||
if request.synonym:
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"Синоним: {request.synonym}"))
|
||
for attribute in request.attributes:
|
||
required = " обязательный" if attribute.required else ""
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"Реквизит.{attribute.name}: {attribute.type}{required}"))
|
||
for section in request.tabular_sections:
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"ТабличнаяЧасть.{section.name}"))
|
||
if section.synonym:
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"ТабличнаяЧасть.{section.name}.Синоним: {section.synonym}"))
|
||
for attribute in section.attributes:
|
||
required = " обязательный" if attribute.required else ""
|
||
diff.append(
|
||
AuthoringDiffLine(
|
||
kind="ADD",
|
||
text=f"ТабличнаяЧасть.{section.name}.Реквизит.{attribute.name}: {attribute.type}{required}",
|
||
)
|
||
)
|
||
for form in request.forms:
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"Форма.{form}"))
|
||
for command in request.commands:
|
||
handler = f" -> {command.handler}" if command.handler else ""
|
||
diff.append(AuthoringDiffLine(kind="ADD", text=f"Команда.{command.name}{handler}"))
|
||
return diff
|
||
|
||
|
||
def _metadata_object_version_preview(
|
||
target: NamedNode,
|
||
request: AuthoringMetadataObjectPreviewRequest,
|
||
diff: list[AuthoringDiffLine],
|
||
) -> AuthoringVersionPreview:
|
||
object_hash = stable_hash(
|
||
json.dumps(
|
||
{
|
||
"target": target.model_dump(mode="json"),
|
||
"draft": request.model_dump(
|
||
mode="json",
|
||
exclude={"expected_next_version_id", "approved_by", "approval_note", "apply_to_production"},
|
||
),
|
||
"diff": [line.model_dump(mode="json") for line in diff],
|
||
},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
)
|
||
current = _versions.latest(target.lineage_id)
|
||
return AuthoringVersionPreview(
|
||
lineage_id=target.lineage_id,
|
||
semantic_id=f"metadata-object:{target.qualified_name}",
|
||
current_version_id=current.version_id if current else None,
|
||
next_version_id=f"version.{target.lineage_id}.{object_hash}",
|
||
object_hash=object_hash,
|
||
task_id=request.task_id,
|
||
session_id=request.session_id,
|
||
apply_available=True,
|
||
)
|
||
|
||
|
||
def _authoring_metadata_object_preview(
|
||
project_id: str,
|
||
request: AuthoringMetadataObjectPreviewRequest,
|
||
) -> AuthoringMetadataObjectPreviewResponse:
|
||
_snapshot_and_graph(project_id)
|
||
_validate_metadata_object_draft(request)
|
||
target = _metadata_object_target(request)
|
||
diff = _metadata_object_diff_lines(request)
|
||
checks = [
|
||
AuthoringGuardCheck(name="preview", status="REQUIRED", message="Metadata draft must be reviewed before apply"),
|
||
AuthoringGuardCheck(name="workspace-history", status="READY", message="Draft can be saved to SFERA workspace history"),
|
||
AuthoringGuardCheck(name="production-1c", status="BLOCKED", message="Production 1C metadata write is disabled"),
|
||
_authoring_task_session_check(project_id, request.task_id, request.session_id, request.user_id),
|
||
_authoring_rbac_check(request.user_id),
|
||
]
|
||
return AuthoringMetadataObjectPreviewResponse(
|
||
project_id=project_id,
|
||
target=target,
|
||
changed=bool(diff),
|
||
added_lines=len(diff),
|
||
removed_lines=0,
|
||
semantic_diff=diff,
|
||
checks=checks,
|
||
version_preview=_metadata_object_version_preview(target, request, diff),
|
||
)
|
||
|
||
|
||
def _persist_authoring_metadata_object(
|
||
project_id: str,
|
||
preview: AuthoringMetadataObjectPreviewResponse,
|
||
request: AuthoringApplyMetadataObjectRequest,
|
||
) -> tuple[SemanticObjectVersion, str, str]:
|
||
previous = _versions.latest(preview.version_preview.lineage_id)
|
||
version = SemanticObjectVersion(
|
||
version_id=preview.version_preview.next_version_id,
|
||
lineage_id=preview.version_preview.lineage_id,
|
||
semantic_id=preview.version_preview.semantic_id,
|
||
object_hash=preview.version_preview.object_hash,
|
||
task_id=request.task_id,
|
||
session_id=request.session_id,
|
||
parent_version_id=previous.version_id if previous else None,
|
||
payload={
|
||
"kind": "METADATA_OBJECT_DRAFT",
|
||
"draft": request.model_dump(mode="json", exclude={"expected_next_version_id", "approved_by", "approval_note", "apply_to_production"}),
|
||
"semantic_diff": [line.model_dump(mode="json") for line in preview.semantic_diff],
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
},
|
||
)
|
||
stored = _versions.upsert_version(version)
|
||
_storage.write_document("object_versions", stored.version_id, stored.model_dump(mode="json"))
|
||
change_id = f"metadata-change.{preview.version_preview.object_hash}"
|
||
payload = {
|
||
"change_id": change_id,
|
||
"project_id": project_id,
|
||
"status": "METADATA_DRAFT_APPLIED_TO_WORKSPACE",
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
"request": request.model_dump(mode="json"),
|
||
"preview": preview.model_dump(mode="json"),
|
||
"version": stored.model_dump(mode="json"),
|
||
}
|
||
path = _storage.write_document("authoring_changes", change_id, payload)
|
||
return stored, change_id, path.as_posix()
|
||
|
||
|
||
def get_review_payload(snapshot: SirSnapshot) -> list[dict]:
|
||
findings = [finding.model_dump(mode="json") for finding in review_snapshot(snapshot)]
|
||
findings.extend(_schema_knowledge_review(snapshot))
|
||
findings.extend(_ownership_review(snapshot))
|
||
findings.extend(_privacy_review(snapshot))
|
||
return findings
|
||
|
||
|
||
def _first_node(snapshot: SirSnapshot, kinds: set[NodeKind]):
|
||
return next((node for node in snapshot.nodes if node.kind in kinds), None)
|
||
|
||
|
||
def _find_routine_node(snapshot: SirSnapshot, routine_name: str | None, cursor_line: int | None):
|
||
routine_kinds = {NodeKind.PROCEDURE, NodeKind.FUNCTION}
|
||
if routine_name:
|
||
wanted = routine_name.lower()
|
||
found = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in routine_kinds and (node.name.lower() == wanted or node.qualified_name.lower() == wanted)
|
||
),
|
||
None,
|
||
)
|
||
if found is not None:
|
||
return found
|
||
if cursor_line is not None:
|
||
found = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in routine_kinds
|
||
and node.source_ref.line_start is not None
|
||
and node.source_ref.line_end is not None
|
||
and node.source_ref.line_start <= cursor_line <= node.source_ref.line_end
|
||
),
|
||
None,
|
||
)
|
||
if found is not None:
|
||
return found
|
||
return next((node for node in snapshot.nodes if node.kind in routine_kinds), None)
|
||
|
||
|
||
def _relation_targets(graph: InMemoryProjection, source, edge_kind: EdgeKind):
|
||
return sorted(
|
||
[
|
||
graph.nodes[edge.target_lineage]
|
||
for edge in graph.edges.values()
|
||
if edge.kind == edge_kind
|
||
and source is not None
|
||
and edge.source_lineage == source.lineage_id
|
||
and edge.target_lineage in graph.nodes
|
||
],
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
|
||
|
||
def _routine_query_tables(graph: InMemoryProjection, routine) -> list:
|
||
if routine is None:
|
||
return []
|
||
query_lineages = {
|
||
edge.target_lineage
|
||
for edge in graph.edges.values()
|
||
if edge.kind == EdgeKind.OWNS_QUERY
|
||
and edge.source_lineage == routine.lineage_id
|
||
and edge.target_lineage in graph.nodes
|
||
}
|
||
tables = [
|
||
graph.nodes[edge.target_lineage]
|
||
for edge in graph.edges.values()
|
||
if edge.kind == EdgeKind.READS_TABLE
|
||
and edge.source_lineage in query_lineages
|
||
and edge.target_lineage in graph.nodes
|
||
]
|
||
return sorted({node.lineage_id: node for node in tables}.values(), key=lambda item: item.qualified_name)
|
||
|
||
|
||
def _source_text_for_node(node) -> str:
|
||
if node is None:
|
||
return ""
|
||
path = Path(node.source_ref.source_path)
|
||
if not path.exists():
|
||
return ""
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def _extract_parameters(source_text: str, routine_name: str | None) -> list[str]:
|
||
if not source_text or not routine_name:
|
||
return []
|
||
pattern = re.compile(rf"(?:Процедура|Функция|Procedure|Function)\s+{re.escape(routine_name)}\s*\(([^)]*)\)", re.IGNORECASE)
|
||
match = pattern.search(source_text)
|
||
if match is None:
|
||
return []
|
||
return [
|
||
item.strip().split("=")[0].strip()
|
||
for item in match.group(1).split(",")
|
||
if item.strip()
|
||
]
|
||
|
||
|
||
def _extract_local_variables(source_text: str, cursor_line: int | None) -> list[str]:
|
||
if not source_text:
|
||
return []
|
||
lines = source_text.splitlines()
|
||
scope = lines[:cursor_line] if cursor_line is not None and cursor_line > 0 else lines
|
||
variables: set[str] = set()
|
||
assignment_pattern = re.compile(r"^\s*([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*=", re.IGNORECASE)
|
||
new_pattern = re.compile(r"^\s*([A-Za-zА-Яа-я_][\wА-Яа-я]*)\s*=\s*Новый\s+", re.IGNORECASE)
|
||
for line in scope:
|
||
for pattern in (new_pattern, assignment_pattern):
|
||
match = pattern.match(line)
|
||
if match:
|
||
variables.add(match.group(1))
|
||
return sorted(variables)
|
||
|
||
|
||
def _available_1c_methods(object_node, routine_node) -> list[str]:
|
||
methods = ["ЗначениеЗаполнено", "ВызватьИсключение", "Записать", "Сообщить"]
|
||
if object_node is not None and object_node.kind == NodeKind.DOCUMENT:
|
||
methods.extend(["Движения", "Проведение", "Отказ"])
|
||
if routine_node is not None:
|
||
methods.extend(["Возврат", "Новый Запрос", "ЭтотОбъект"])
|
||
return sorted(set(methods))
|
||
|
||
|
||
@app.get("/projects/{project_id}/access/objects/{object_name}/roles", response_model=ObjectAccessResponse)
|
||
async def get_object_access(project_id: str, object_name: str) -> ObjectAccessResponse:
|
||
graph = _graphs.get(project_id)
|
||
if graph is None:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}")
|
||
object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectAccessResponse(
|
||
object=_named_node(object_node),
|
||
grants=[
|
||
RoleAccessResponse(role=_named_node(role), permissions=edge.attributes)
|
||
for role, edge in _object_access_grants(graph, object_node)
|
||
],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/access/roles/{role_name}/objects", response_model=RoleObjectAccessResponse)
|
||
async def get_role_access(project_id: str, role_name: str) -> RoleObjectAccessResponse:
|
||
graph = _graphs.get(project_id)
|
||
if graph is None:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}")
|
||
role = _find_graph_node(graph, role_name, {NodeKind.ROLE})
|
||
if role is None:
|
||
raise HTTPException(status_code=404, detail=f"Role not found: {role_name}")
|
||
grants = _role_object_grants(graph, role)
|
||
return RoleObjectAccessResponse(
|
||
role=_named_node(role),
|
||
objects=[_named_node(target) for target, _edge in grants],
|
||
grants=[
|
||
{"object": _named_node(target), "permissions": edge.attributes}
|
||
for target, edge in grants
|
||
],
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/review")
|
||
async def get_review(project_id: str) -> list[dict]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return get_review_payload(snapshot)
|
||
|
||
|
||
@app.get("/projects/{project_id}/search", response_model=SearchResponse)
|
||
async def search(project_id: str, q: str, kind: str | None = None, limit: int = 20) -> SearchResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
kinds = {kind.upper()} if kind else None
|
||
return SearchResponse(
|
||
results=[
|
||
_named_node(result.node)
|
||
for result in search_snapshot(snapshot, q, kinds=kinds, limit=limit)
|
||
]
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/symbols", response_model=list[SymbolResponse])
|
||
async def project_symbols(project_id: str, q: str = "", kind: str | None = None, limit: int = 50) -> list[SymbolResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
normalized_limit = min(max(1, limit), 250)
|
||
kinds = {kind.upper()} if kind else None
|
||
if q.strip():
|
||
nodes = [result.node for result in search_snapshot(snapshot, q, kinds=kinds, limit=normalized_limit)]
|
||
else:
|
||
nodes = [
|
||
node
|
||
for node in snapshot.nodes
|
||
if kinds is None or node.kind.value in kinds
|
||
]
|
||
nodes.sort(key=lambda item: (item.qualified_name.casefold(), item.kind.value))
|
||
nodes = nodes[:normalized_limit]
|
||
return [_symbol_response(node) for node in nodes]
|
||
|
||
|
||
@app.get("/projects/{project_id}/symbols/definition", response_model=SymbolResponse)
|
||
async def project_symbol_definition(project_id: str, lineage_id: str) -> SymbolResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
node = _find_snapshot_node(snapshot, lineage_id)
|
||
if node is None:
|
||
raise HTTPException(status_code=404, detail=f"Symbol not found: {lineage_id}")
|
||
return _symbol_response(node)
|
||
|
||
|
||
@app.get("/projects/{project_id}/symbols/references", response_model=SymbolReferencesResponse)
|
||
async def project_symbol_references(
|
||
project_id: str,
|
||
lineage_id: str,
|
||
direction: str = "incoming",
|
||
) -> SymbolReferencesResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
node = _find_snapshot_node(snapshot, lineage_id)
|
||
if node is None:
|
||
raise HTTPException(status_code=404, detail=f"Symbol not found: {lineage_id}")
|
||
normalized_direction = direction.casefold()
|
||
if normalized_direction not in {"incoming", "outgoing", "both"}:
|
||
raise HTTPException(status_code=422, detail="direction must be incoming, outgoing, or both")
|
||
nodes = {item.lineage_id: item for item in snapshot.nodes}
|
||
references: list[SymbolReferenceResponse] = []
|
||
for edge in snapshot.edges:
|
||
include_incoming = normalized_direction in {"incoming", "both"} and edge.target_lineage == lineage_id
|
||
include_outgoing = normalized_direction in {"outgoing", "both"} and edge.source_lineage == lineage_id
|
||
if not include_incoming and not include_outgoing:
|
||
continue
|
||
source = nodes.get(edge.source_lineage)
|
||
target = nodes.get(edge.target_lineage)
|
||
references.append(
|
||
SymbolReferenceResponse(
|
||
edge_id=edge.edge_id,
|
||
kind=edge.kind.value,
|
||
direction="incoming" if include_incoming else "outgoing",
|
||
source=_named_node(source) if source is not None else None,
|
||
target=_named_node(target) if target is not None else None,
|
||
location=_source_location(edge.source_ref),
|
||
attributes=edge.attributes,
|
||
)
|
||
)
|
||
references.sort(key=lambda item: (
|
||
item.location.source_path if item.location else "",
|
||
item.location.line_start or 0 if item.location else 0,
|
||
item.kind,
|
||
item.source.qualified_name if item.source else "",
|
||
item.target.qualified_name if item.target else "",
|
||
))
|
||
return SymbolReferencesResponse(symbol=_symbol_response(node), references=references)
|
||
|
||
|
||
@app.get("/projects/{project_id}/tables/usage", response_model=list[TableUsageResponse])
|
||
async def get_table_usage(project_id: str, table: str | None = None) -> list[TableUsageResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return [
|
||
TableUsageResponse(
|
||
table=_named_node(item.table),
|
||
queries=[_named_node(node) for node in item.queries],
|
||
readers=[_named_node(node) for node in item.readers],
|
||
writers=[_named_node(node) for node in item.writers],
|
||
)
|
||
for item in table_usage(snapshot, table)
|
||
]
|
||
|
||
|
||
@app.get("/projects/{project_id}/patterns", response_model=list[SemanticPattern])
|
||
async def get_project_patterns(project_id: str, min_support: int = 2) -> list[SemanticPattern]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return mine_patterns(snapshot, min_support=min_support)
|
||
|
||
|
||
@app.get("/projects/{project_id}/transactions/writes", response_model=list[TransactionWriteSetResponse])
|
||
async def get_transaction_writes(
|
||
project_id: str,
|
||
target: str | None = None,
|
||
) -> list[TransactionWriteSetResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
if target:
|
||
routines = {node.lineage_id for node in routines_touching_target(snapshot, target)}
|
||
write_sets = [
|
||
item for item in transaction_write_sets(snapshot) if item.routine.lineage_id in routines
|
||
]
|
||
else:
|
||
write_sets = transaction_write_sets(snapshot)
|
||
return [
|
||
TransactionWriteSetResponse(
|
||
routine=_named_node(item.routine),
|
||
writes=[_named_node(node) for node in item.writes],
|
||
)
|
||
for item in write_sets
|
||
]
|
||
|
||
|
||
@app.get("/projects/{project_id}/ui/forms", response_model=list[FormSemanticsResponse])
|
||
async def get_ui_forms(project_id: str) -> list[FormSemanticsResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return [
|
||
FormSemanticsResponse(
|
||
form=_named_node(item.form),
|
||
commands=[_named_node(node) for node in item.commands],
|
||
elements=[_named_node(node) for node in item.elements],
|
||
command_handlers={
|
||
command_lineage: _named_node(handler)
|
||
for command_lineage, handler in item.command_handlers.items()
|
||
},
|
||
)
|
||
for item in form_semantics(snapshot)
|
||
]
|
||
|
||
|
||
@app.get("/projects/{project_id}/objects/ui/{object_name}", response_model=ObjectUiResponse)
|
||
async def get_object_ui(project_id: str, object_name: str) -> ObjectUiResponse:
|
||
snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _ACCESS_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
form_lineages = {
|
||
edge.target_lineage
|
||
for edge in graph.edges.values()
|
||
if edge.kind == EdgeKind.HAS_FORM and edge.source_lineage == object_node.lineage_id
|
||
}
|
||
return ObjectUiResponse(
|
||
object=_named_node(object_node),
|
||
forms=_form_semantics_for_lineages(snapshot, form_lineages),
|
||
)
|
||
|
||
|
||
@app.post("/projects/{project_id}/authoring/context", response_model=AuthoringContextResponse)
|
||
async def authoring_context(
|
||
project_id: str,
|
||
request: AuthoringContextRequest,
|
||
) -> AuthoringContextResponse:
|
||
return _authoring_context(project_id, request)
|
||
|
||
|
||
@app.post("/projects/{project_id}/authoring/completion-preview", response_model=AuthoringCompletionPreviewResponse)
|
||
async def authoring_completion_preview(
|
||
project_id: str,
|
||
request: AuthoringCompletionPreviewRequest,
|
||
) -> AuthoringCompletionPreviewResponse:
|
||
context = _authoring_context(project_id, request)
|
||
insert_text = _authoring_insert_text(context, request)
|
||
checks = _authoring_guard_checks(project_id, context, request)
|
||
return AuthoringCompletionPreviewResponse(
|
||
allowed=all(check.status != "BLOCKED" for check in checks),
|
||
insert_text=insert_text,
|
||
semantic_diff=[
|
||
AuthoringDiffLine(kind="ADD", text=line)
|
||
for line in insert_text.splitlines()
|
||
if line.strip()
|
||
],
|
||
checks=checks,
|
||
context=context,
|
||
)
|
||
|
||
|
||
@app.post("/projects/{project_id}/authoring/semantic-diff-preview", response_model=AuthoringSemanticDiffPreviewResponse)
|
||
async def authoring_semantic_diff_preview(
|
||
project_id: str,
|
||
request: AuthoringSemanticDiffPreviewRequest,
|
||
) -> AuthoringSemanticDiffPreviewResponse:
|
||
return _authoring_semantic_diff_preview(project_id, request)
|
||
|
||
|
||
@app.post("/projects/{project_id}/authoring/apply-change-set", response_model=AuthoringApplyChangeSetResponse)
|
||
async def authoring_apply_change_set(
|
||
project_id: str,
|
||
request: AuthoringApplyChangeSetRequest,
|
||
) -> AuthoringApplyChangeSetResponse:
|
||
if request.apply_to_production:
|
||
raise HTTPException(status_code=403, detail="Production 1C apply is not enabled")
|
||
preview = _authoring_semantic_diff_preview(project_id, request)
|
||
if not preview.changed:
|
||
raise HTTPException(status_code=400, detail="No semantic diff to apply")
|
||
if preview.version_preview is None:
|
||
raise HTTPException(status_code=400, detail="No target object for versioned apply")
|
||
if preview.version_preview.next_version_id != request.expected_next_version_id:
|
||
raise HTTPException(status_code=409, detail="Expected version id does not match current preview")
|
||
blocking_checks = [
|
||
check for check in preview.checks
|
||
if check.status == "BLOCKED" and check.name not in {"apply"}
|
||
]
|
||
if blocking_checks:
|
||
raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]})
|
||
version = _persist_authoring_version(preview, request)
|
||
change_id = f"change.{preview.version_preview.object_hash}"
|
||
payload = {
|
||
"change_id": change_id,
|
||
"project_id": project_id,
|
||
"status": "APPLIED_TO_WORKSPACE",
|
||
"approved_by": request.approved_by,
|
||
"approval_note": request.approval_note,
|
||
"production_applied": False,
|
||
"request": request.model_dump(mode="json"),
|
||
"preview": preview.model_dump(mode="json"),
|
||
"version": version.model_dump(mode="json"),
|
||
}
|
||
path = _storage.write_document("authoring_changes", change_id, payload)
|
||
return AuthoringApplyChangeSetResponse(
|
||
project_id=project_id,
|
||
status="APPLIED_TO_WORKSPACE",
|
||
change_id=change_id,
|
||
version=version,
|
||
preview=preview,
|
||
persisted_path=path.as_posix(),
|
||
production_applied=False,
|
||
)
|
||
|
||
|
||
@app.post(
|
||
"/projects/{project_id}/authoring/metadata-object-preview",
|
||
response_model=AuthoringMetadataObjectPreviewResponse,
|
||
)
|
||
async def authoring_metadata_object_preview(
|
||
project_id: str,
|
||
request: AuthoringMetadataObjectPreviewRequest,
|
||
) -> AuthoringMetadataObjectPreviewResponse:
|
||
return _authoring_metadata_object_preview(project_id, request)
|
||
|
||
|
||
@app.post(
|
||
"/projects/{project_id}/authoring/apply-metadata-object",
|
||
response_model=AuthoringApplyMetadataObjectResponse,
|
||
)
|
||
async def authoring_apply_metadata_object(
|
||
project_id: str,
|
||
request: AuthoringApplyMetadataObjectRequest,
|
||
) -> AuthoringApplyMetadataObjectResponse:
|
||
if request.apply_to_production:
|
||
raise HTTPException(status_code=403, detail="Production 1C metadata apply is not enabled")
|
||
preview = _authoring_metadata_object_preview(project_id, request)
|
||
if not preview.changed:
|
||
raise HTTPException(status_code=400, detail="No metadata draft to apply")
|
||
if preview.version_preview.next_version_id != request.expected_next_version_id:
|
||
raise HTTPException(status_code=409, detail="Expected version id does not match current metadata preview")
|
||
blocking_checks = [
|
||
check for check in preview.checks
|
||
if check.status == "BLOCKED" and check.name not in {"production-1c"}
|
||
]
|
||
if blocking_checks:
|
||
raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]})
|
||
version, change_id, path = _persist_authoring_metadata_object(project_id, preview, request)
|
||
return AuthoringApplyMetadataObjectResponse(
|
||
project_id=project_id,
|
||
status="METADATA_DRAFT_APPLIED_TO_WORKSPACE",
|
||
change_id=change_id,
|
||
version=version,
|
||
preview=preview,
|
||
persisted_path=path,
|
||
production_applied=False,
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/authoring/changes", response_model=list[AuthoringChangeSummary])
|
||
async def list_authoring_changes(project_id: str) -> list[AuthoringChangeSummary]:
|
||
return _authoring_change_summaries(project_id)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/authoring/changes/{change_id}/rollback-preview",
|
||
response_model=AuthoringRollbackPreviewResponse,
|
||
)
|
||
async def authoring_rollback_preview(project_id: str, change_id: str) -> AuthoringRollbackPreviewResponse:
|
||
return _authoring_rollback_preview(project_id, change_id)
|
||
|
||
|
||
@app.post(
|
||
"/projects/{project_id}/authoring/changes/{change_id}/apply-rollback",
|
||
response_model=AuthoringApplyRollbackResponse,
|
||
)
|
||
async def authoring_apply_rollback(
|
||
project_id: str,
|
||
change_id: str,
|
||
request: AuthoringApplyRollbackRequest,
|
||
) -> AuthoringApplyRollbackResponse:
|
||
if request.apply_to_production:
|
||
raise HTTPException(status_code=403, detail="Production 1C rollback is not enabled")
|
||
change_payload = _authoring_change_payload(project_id, change_id)
|
||
preview = _authoring_rollback_preview(project_id, change_id)
|
||
if preview.rollback_version_id != request.expected_rollback_version_id:
|
||
raise HTTPException(status_code=409, detail="Expected rollback version id does not match current preview")
|
||
if not preview.apply_available:
|
||
raise HTTPException(status_code=409, detail="Rollback apply is not available")
|
||
apply_checks = [
|
||
_authoring_task_session_check(project_id, request.task_id, request.session_id),
|
||
_authoring_rbac_check(request.approved_by),
|
||
]
|
||
blocking_checks = [check for check in apply_checks if check.status == "BLOCKED"]
|
||
if blocking_checks:
|
||
raise HTTPException(status_code=409, detail={"blocked_checks": [check.model_dump(mode="json") for check in blocking_checks]})
|
||
version, path = _persist_authoring_rollback(project_id, change_payload, preview, request)
|
||
return AuthoringApplyRollbackResponse(
|
||
project_id=project_id,
|
||
status="ROLLED_BACK_TO_WORKSPACE",
|
||
change_id=change_id,
|
||
rollback_change_id=f"rollback.{change_id}",
|
||
version=version,
|
||
preview=preview,
|
||
persisted_path=path,
|
||
production_applied=False,
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/jobs/scheduled", response_model=list[ScheduledJobResponse])
|
||
async def get_scheduled_jobs(project_id: str) -> list[ScheduledJobResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return [
|
||
ScheduledJobResponse(
|
||
job_id=binding.job.job_id,
|
||
name=binding.job.name,
|
||
routine_name=binding.job.routine_name,
|
||
schedule=binding.job.schedule,
|
||
routine=_named_node(binding.routine) if binding.routine is not None else None,
|
||
attributes=binding.job.attributes,
|
||
)
|
||
for binding in snapshot_scheduled_jobs(snapshot)
|
||
]
|
||
|
||
|
||
@app.get("/projects/{project_id}/integrations", response_model=list[IntegrationEndpointResponse])
|
||
async def get_integrations(project_id: str, kind: IntegrationKind | None = None) -> list[IntegrationEndpointResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
topology = build_integration_topology(snapshot)
|
||
endpoints = topology.by_kind(kind) if kind is not None else topology.endpoints
|
||
return [
|
||
IntegrationEndpointResponse(
|
||
endpoint_id=endpoint.endpoint_id,
|
||
name=endpoint.name,
|
||
kind=endpoint.kind.value,
|
||
direction=endpoint.direction,
|
||
owner=endpoint.owner,
|
||
attributes=endpoint.attributes,
|
||
)
|
||
for endpoint in endpoints
|
||
]
|
||
|
||
|
||
@app.get("/graph/neo4j/status")
|
||
async def neo4j_status() -> dict:
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
counts = await Neo4jProjection(driver).counts()
|
||
return {"status": "ok", "uri": _neo4j_uri, **counts}
|
||
except Exception as error:
|
||
return {"status": "unavailable", "uri": _neo4j_uri, "error": str(error)}
|
||
|
||
|
||
@app.post("/projects/{project_id}/graph/neo4j/project", response_model=Neo4jProjectionResponse)
|
||
async def project_to_neo4j(project_id: str) -> Neo4jProjectionResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
counts = await _project_snapshot_to_neo4j(project_id, snapshot)
|
||
_neo4j_projected_projects.add(project_id)
|
||
return Neo4jProjectionResponse(
|
||
project_id=project_id,
|
||
nodes=counts["nodes"],
|
||
edges=counts["edges"],
|
||
status="projected",
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/callees/{routine_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_callees(project_id: str, routine_name: str) -> SearchResponse:
|
||
rows = await _neo4j_routine_query(project_id, routine_name, outgoing=True)
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/callers/{routine_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_callers(project_id: str, routine_name: str) -> SearchResponse:
|
||
rows = await _neo4j_routine_query(project_id, routine_name, outgoing=False)
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/query-tables/{routine_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_query_tables(project_id: str, routine_name: str) -> SearchResponse:
|
||
rows = await _neo4j_relation_query(project_id, routine_name, relation="READS_TABLE")
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/writes/{routine_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_writes(project_id: str, routine_name: str) -> SearchResponse:
|
||
rows = await _neo4j_relation_query(project_id, routine_name, relation="WRITES")
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/integrations",
|
||
response_model=list[IntegrationEndpointResponse],
|
||
)
|
||
async def neo4j_integrations(project_id: str, kind: IntegrationKind | None = None) -> list[IntegrationEndpointResponse]:
|
||
rows = await _neo4j_integrations_query(project_id, kind=kind.value if kind is not None else None)
|
||
return [IntegrationEndpointResponse(**row) for row in rows]
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/integrations/{integration_name:path}/modules",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_integration_modules(project_id: str, integration_name: str) -> SearchResponse:
|
||
rows = await _neo4j_integration_modules_query(project_id, integration_name)
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/schema/{object_name}",
|
||
response_model=ObjectSchemaResponse,
|
||
)
|
||
async def neo4j_object_schema(project_id: str, object_name: str) -> ObjectSchemaResponse:
|
||
payload = await _neo4j_object_schema_query(project_id, object_name)
|
||
if payload is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectSchemaResponse(
|
||
object=NamedNode(**payload["object"]),
|
||
attributes=[NamedNode(**row) for row in payload["attributes"]],
|
||
tabular_sections=[
|
||
TabularSectionColumnsResponse(
|
||
tabular_section=NamedNode(**row["tabular_section"]),
|
||
columns=[NamedNode(**column) for column in row["columns"]],
|
||
)
|
||
for row in payload["tabular_sections"]
|
||
],
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/attributes/{object_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_object_attributes(project_id: str, object_name: str) -> SearchResponse:
|
||
rows = await _neo4j_object_attributes_query(project_id, object_name)
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}",
|
||
response_model=SearchResponse,
|
||
)
|
||
async def neo4j_object_tabular_sections(project_id: str, object_name: str) -> SearchResponse:
|
||
rows = await _neo4j_object_tabular_sections_query(project_id, object_name)
|
||
return SearchResponse(results=[NamedNode(**row) for row in rows])
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/tabular-sections/{object_name}/columns",
|
||
response_model=list[TabularSectionColumnsResponse],
|
||
)
|
||
async def neo4j_object_tabular_section_columns(
|
||
project_id: str,
|
||
object_name: str,
|
||
) -> list[TabularSectionColumnsResponse]:
|
||
rows = await _neo4j_object_tabular_section_columns_query(project_id, object_name)
|
||
return [
|
||
TabularSectionColumnsResponse(
|
||
tabular_section=NamedNode(**row["tabular_section"]),
|
||
columns=[NamedNode(**column) for column in row["columns"]],
|
||
)
|
||
for row in rows
|
||
]
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/impact/{object_name}",
|
||
response_model=ObjectImpactResponse,
|
||
)
|
||
async def neo4j_object_impact(project_id: str, object_name: str) -> ObjectImpactResponse:
|
||
payload = await _neo4j_object_impact_query(project_id, object_name)
|
||
if payload is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectImpactResponse(
|
||
object_name=object_name,
|
||
object=NamedNode(**payload["object"]),
|
||
modules=[NamedNode(**row) for row in payload["modules"]],
|
||
routines=[NamedNode(**row) for row in payload["routines"]],
|
||
forms=[NamedNode(**row) for row in payload["forms"]],
|
||
commands=[NamedNode(**row) for row in payload["commands"]],
|
||
attributes=[NamedNode(**row) for row in payload.get("attributes", [])],
|
||
tabular_sections=[NamedNode(**row) for row in payload.get("tabular_sections", [])],
|
||
tabular_section_columns={
|
||
section_lineage: [NamedNode(**column) for column in columns]
|
||
for section_lineage, columns in payload.get("tabular_section_columns", {}).items()
|
||
},
|
||
roles=[NamedNode(**row) for row in payload.get("roles", [])],
|
||
role_access=[
|
||
RoleAccessResponse(role=NamedNode(**grant["role"]), permissions=grant["permissions"])
|
||
for grant in payload.get("role_access", [])
|
||
],
|
||
jobs=[NamedNode(**row) for row in payload.get("jobs", [])],
|
||
callees=[NamedNode(**row) for row in payload["callees"]],
|
||
query_tables=[NamedNode(**row) for row in payload["query_tables"]],
|
||
writes=[NamedNode(**row) for row in payload["writes"]],
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/objects/ui/{object_name}",
|
||
response_model=ObjectUiResponse,
|
||
)
|
||
async def neo4j_object_ui(project_id: str, object_name: str) -> ObjectUiResponse:
|
||
payload = await _neo4j_object_ui_query(project_id, object_name)
|
||
if payload is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectUiResponse(
|
||
object=NamedNode(**payload["object"]),
|
||
forms=[
|
||
FormSemanticsResponse(
|
||
form=NamedNode(**form["form"]),
|
||
commands=[NamedNode(**row) for row in form["commands"]],
|
||
elements=[NamedNode(**row) for row in form["elements"]],
|
||
command_handlers={
|
||
command_lineage: NamedNode(**handler)
|
||
for command_lineage, handler in form["command_handlers"].items()
|
||
},
|
||
)
|
||
for form in payload["forms"]
|
||
],
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/access/objects/{object_name}/roles",
|
||
response_model=ObjectAccessResponse,
|
||
)
|
||
async def neo4j_object_access(project_id: str, object_name: str) -> ObjectAccessResponse:
|
||
payload = await _neo4j_object_access_query(project_id, object_name)
|
||
if payload is None:
|
||
raise HTTPException(status_code=404, detail=f"Object not found: {object_name}")
|
||
return ObjectAccessResponse(
|
||
object=NamedNode(**payload["object"]),
|
||
grants=[
|
||
RoleAccessResponse(role=NamedNode(**grant["role"]), permissions=grant["permissions"])
|
||
for grant in payload["grants"]
|
||
],
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/graph/neo4j/access/roles/{role_name}/objects",
|
||
response_model=RoleObjectAccessResponse,
|
||
)
|
||
async def neo4j_role_access(project_id: str, role_name: str) -> RoleObjectAccessResponse:
|
||
payload = await _neo4j_role_access_query(project_id, role_name)
|
||
if payload is None:
|
||
raise HTTPException(status_code=404, detail=f"Role not found: {role_name}")
|
||
return RoleObjectAccessResponse(
|
||
role=NamedNode(**payload["role"]),
|
||
objects=[NamedNode(**row) for row in payload["objects"]],
|
||
grants=[
|
||
{"object": NamedNode(**grant["object"]), "permissions": grant["permissions"]}
|
||
for grant in payload["grants"]
|
||
],
|
||
)
|
||
|
||
|
||
@app.post("/projects/{project_id}/runtime/signals")
|
||
async def add_runtime_signal(project_id: str, request: RuntimeSignalRequest) -> dict[str, str]:
|
||
if project_id not in _snapshots:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}")
|
||
overlay = _overlays.setdefault(project_id, RuntimeOverlay(project_id=project_id))
|
||
overlay.signals.append(request.signal)
|
||
return {"status": "accepted"}
|
||
|
||
|
||
@app.get("/projects/{project_id}/runtime/summary", response_model=list[RuntimeSummaryResponse])
|
||
async def get_runtime_summary(project_id: str) -> list[RuntimeSummaryResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
overlay = _overlays.get(project_id, RuntimeOverlay(project_id=project_id))
|
||
return [
|
||
RuntimeSummaryResponse(
|
||
node=_named_node(item.node),
|
||
signal_count=item.signal_count,
|
||
error_count=item.error_count,
|
||
max_duration_ms=item.max_duration_ms,
|
||
)
|
||
for item in summarize_runtime(snapshot, overlay)
|
||
]
|
||
|
||
|
||
@app.post("/knowledge", response_model=KnowledgeRecord)
|
||
async def upsert_knowledge(record: KnowledgeRecord) -> KnowledgeRecord:
|
||
stored = _knowledge.upsert(record)
|
||
_storage.write_document("knowledge", stored.record_id, stored.model_dump(mode="json"))
|
||
return stored
|
||
|
||
|
||
@app.post("/knowledge/packs", response_model=KnowledgePack)
|
||
async def import_knowledge_pack(pack: KnowledgePack) -> KnowledgePack:
|
||
stored = _knowledge.import_pack(pack)
|
||
_storage.write_document("knowledge_packs", stored.pack_id, stored.model_dump(mode="json"))
|
||
for record in stored.records:
|
||
enriched = _knowledge.get(record.record_id)
|
||
if enriched is not None:
|
||
_storage.write_document("knowledge", enriched.record_id, enriched.model_dump(mode="json"))
|
||
return stored
|
||
|
||
|
||
@app.get("/knowledge/packs", response_model=list[KnowledgePack])
|
||
async def list_knowledge_packs() -> list[KnowledgePack]:
|
||
return _knowledge.list_packs()
|
||
|
||
|
||
@app.get("/knowledge/search", response_model=KnowledgeSearchResponse)
|
||
async def search_knowledge(
|
||
q: str,
|
||
scope: KnowledgeScope | None = None,
|
||
limit: int = 20,
|
||
) -> KnowledgeSearchResponse:
|
||
return KnowledgeSearchResponse(
|
||
results=[result.record for result in _knowledge.search(q, scope=scope, limit=limit)]
|
||
)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/knowledge/coverage",
|
||
response_model=list[KnowledgeCoverageResponse],
|
||
)
|
||
async def get_knowledge_coverage(project_id: str) -> list[KnowledgeCoverageResponse]:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
return [
|
||
KnowledgeCoverageResponse(node=_named_node(item.node), record_count=item.record_count)
|
||
for item in _knowledge.coverage(snapshot)
|
||
]
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/knowledge/schema-coverage",
|
||
response_model=KnowledgeSchemaCoverageResponse,
|
||
)
|
||
async def get_knowledge_schema_coverage(project_id: str) -> KnowledgeSchemaCoverageResponse:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
schema_kinds = {NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION}
|
||
items = [
|
||
KnowledgeCoverageResponse(node=_named_node(item.node), record_count=item.record_count)
|
||
for item in _knowledge.coverage(snapshot)
|
||
if item.node.kind in schema_kinds
|
||
]
|
||
uncovered = [item.node for item in items if item.record_count == 0]
|
||
return KnowledgeSchemaCoverageResponse(
|
||
items=items,
|
||
uncovered=uncovered,
|
||
covered_count=sum(1 for item in items if item.record_count > 0),
|
||
uncovered_count=len(uncovered),
|
||
)
|
||
|
||
|
||
@app.post("/collaboration/users", response_model=User)
|
||
async def upsert_user(user: User) -> User:
|
||
stored = _collaboration.upsert_user(user)
|
||
_storage.write_document("collaboration_users", stored.user_id, stored.model_dump(mode="json"))
|
||
return stored
|
||
|
||
|
||
@app.post("/collaboration/tasks", response_model=Task)
|
||
async def upsert_task(task: Task) -> Task:
|
||
stored = _collaboration.upsert_task(task)
|
||
_storage.write_document("collaboration_tasks", stored.task_id, stored.model_dump(mode="json"))
|
||
_collaboration.add_activity(
|
||
ActivityEvent(
|
||
event_id=f"activity.{task.task_id}.upsert",
|
||
project_id=task.project_id,
|
||
actor_user_id=task.assignee_user_id or "system",
|
||
verb="UPSERT_TASK",
|
||
target_id=task.task_id,
|
||
)
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.post("/collaboration/sessions", response_model=ChangeSession)
|
||
async def start_change_session(request: CollaborationSessionRequest) -> ChangeSession:
|
||
try:
|
||
stored = _collaboration.start_session(request.session)
|
||
_storage.write_document(
|
||
"collaboration_sessions",
|
||
stored.session_id,
|
||
stored.model_dump(mode="json"),
|
||
)
|
||
return stored
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||
|
||
|
||
@app.post("/collaboration/sessions/{session_id}/finish", response_model=ChangeSession)
|
||
async def finish_change_session(session_id: str) -> ChangeSession:
|
||
try:
|
||
stored = _collaboration.finish_session(session_id)
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||
_storage.write_document(
|
||
"collaboration_sessions",
|
||
stored.session_id,
|
||
stored.model_dump(mode="json"),
|
||
)
|
||
task = _collaboration.tasks.get(stored.task_id)
|
||
_collaboration.add_activity(
|
||
ActivityEvent(
|
||
event_id=f"activity.session.{stored.session_id}.finish",
|
||
project_id=task.project_id if task else "unknown",
|
||
actor_user_id=stored.user_id,
|
||
verb="FINISH_SESSION",
|
||
target_id=stored.session_id,
|
||
)
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.post("/projects/{project_id}/comments", response_model=Comment)
|
||
async def add_project_comment(project_id: str, request: CommentRequest) -> Comment:
|
||
_snapshot_and_graph(project_id)
|
||
try:
|
||
stored = _collaboration.add_comment(
|
||
Comment(
|
||
comment_id=request.comment_id,
|
||
project_id=project_id,
|
||
target_id=request.target_id,
|
||
user_id=request.user_id,
|
||
body=request.body,
|
||
)
|
||
)
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||
_storage.write_document("collaboration_comments", stored.comment_id, stored.model_dump(mode="json"))
|
||
_collaboration.add_activity(
|
||
ActivityEvent(
|
||
event_id=f"activity.comment.{stored.comment_id}",
|
||
project_id=project_id,
|
||
actor_user_id=stored.user_id,
|
||
verb="ADD_COMMENT",
|
||
target_id=stored.target_id,
|
||
)
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.get("/projects/{project_id}/comments", response_model=list[Comment])
|
||
async def project_comments(project_id: str) -> list[Comment]:
|
||
_snapshot_and_graph(project_id)
|
||
return _collaboration.comments_for_project(project_id)
|
||
|
||
|
||
@app.get("/projects/{project_id}/comments/{target_id:path}", response_model=list[Comment])
|
||
async def target_comments(project_id: str, target_id: str) -> list[Comment]:
|
||
_snapshot_and_graph(project_id)
|
||
return _collaboration.comments_for_target(project_id, target_id)
|
||
|
||
|
||
@app.post("/projects/{project_id}/ownership", response_model=Ownership)
|
||
async def assign_project_owner(project_id: str, request: OwnershipRequest) -> Ownership:
|
||
snapshot, _graph = _snapshot_and_graph(project_id)
|
||
target = next((node for node in snapshot.nodes if node.lineage_id == request.target_id), None)
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail=f"Target lineage not found: {request.target_id}")
|
||
try:
|
||
stored = _collaboration.assign_owner(
|
||
Ownership(
|
||
project_id=project_id,
|
||
target_id=request.target_id,
|
||
owner_user_id=request.owner_user_id,
|
||
role=request.role,
|
||
assigned_by=request.assigned_by,
|
||
attributes=request.attributes,
|
||
)
|
||
)
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||
document_id = f"{stored.project_id}.{stored.target_id}.{stored.role}.{stored.owner_user_id}"
|
||
_storage.write_document("collaboration_ownership", document_id, stored.model_dump(mode="json"))
|
||
_collaboration.add_activity(
|
||
ActivityEvent(
|
||
event_id=f"activity.ownership.{stored.project_id}.{stored.target_id}.{stored.role}.{stored.owner_user_id}",
|
||
project_id=project_id,
|
||
actor_user_id=stored.assigned_by or stored.owner_user_id,
|
||
verb="ASSIGN_OWNER",
|
||
target_id=stored.target_id,
|
||
attributes={
|
||
"owner_user_id": stored.owner_user_id,
|
||
"role": stored.role,
|
||
"target_name": target.qualified_name,
|
||
},
|
||
)
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.get("/projects/{project_id}/ownership", response_model=list[Ownership])
|
||
async def project_ownership(project_id: str) -> list[Ownership]:
|
||
_snapshot_and_graph(project_id)
|
||
return _collaboration.owners_for_project(project_id)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/objects/ownership/{object_name}",
|
||
response_model=ObjectOwnershipResponse,
|
||
)
|
||
async def object_ownership(project_id: str, object_name: str) -> ObjectOwnershipResponse:
|
||
_snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _OWNERSHIP_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"1C object not found: {object_name}")
|
||
return ObjectOwnershipResponse(
|
||
object=_named_node(object_node),
|
||
owners=_collaboration.owners_for_target(project_id, object_node.lineage_id),
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/activity")
|
||
async def activity_feed(project_id: str) -> list[dict]:
|
||
return [event.model_dump(mode="json") for event in _collaboration.activity_feed(project_id)]
|
||
|
||
|
||
@app.get("/security/users/{user_id}/permissions/{permission}")
|
||
async def check_permission(user_id: str, permission: Permission) -> dict[str, bool | str]:
|
||
return {
|
||
"user_id": user_id,
|
||
"permission": permission.value,
|
||
"allowed": _rbac.is_allowed(user_id, permission),
|
||
}
|
||
|
||
|
||
@app.get("/security/users/{user_id}/permissions")
|
||
async def user_permissions(user_id: str) -> dict[str, list[str] | str]:
|
||
return {
|
||
"user_id": user_id,
|
||
"permissions": sorted(permission.value for permission in _rbac.effective_permissions(user_id)),
|
||
}
|
||
|
||
|
||
@app.post("/security/users/{user_id}/roles/{role_id}")
|
||
async def grant_role(user_id: str, role_id: str) -> dict[str, str]:
|
||
try:
|
||
_rbac.grant_role(user_id, role_id)
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||
_storage.write_document("security_users", user_id, _rbac.users[user_id].model_dump(mode="json"))
|
||
return {"user_id": user_id, "role_id": role_id, "status": "granted"}
|
||
|
||
|
||
@app.post("/projects/{project_id}/privacy/markers", response_model=PrivacyMarker)
|
||
async def upsert_privacy_marker(project_id: str, request: PrivacyMarkerRequest) -> PrivacyMarker:
|
||
snapshot, _graph = _snapshot_and_graph(project_id)
|
||
target = next((node for node in snapshot.nodes if node.lineage_id == request.target_id), None)
|
||
if target is None:
|
||
raise HTTPException(status_code=404, detail=f"Target lineage not found: {request.target_id}")
|
||
stored = _privacy.upsert_marker(
|
||
PrivacyMarker(
|
||
project_id=project_id,
|
||
target_id=request.target_id,
|
||
classification=request.classification,
|
||
reason=request.reason,
|
||
attributes=request.attributes,
|
||
)
|
||
)
|
||
_storage.write_document(
|
||
"privacy_markers",
|
||
f"{stored.project_id}.{stored.target_id}",
|
||
stored.model_dump(mode="json"),
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.get("/projects/{project_id}/privacy/markers", response_model=list[PrivacyMarker])
|
||
async def project_privacy_markers(project_id: str) -> list[PrivacyMarker]:
|
||
_snapshot_and_graph(project_id)
|
||
return _privacy.markers_for_project(project_id)
|
||
|
||
|
||
@app.get(
|
||
"/projects/{project_id}/objects/privacy/{object_name}",
|
||
response_model=ObjectPrivacyResponse,
|
||
)
|
||
async def object_privacy(project_id: str, object_name: str) -> ObjectPrivacyResponse:
|
||
_snapshot, graph = _snapshot_and_graph(project_id)
|
||
object_node = _find_graph_node(graph, object_name, _PRIVACY_TARGET_KINDS)
|
||
if object_node is None:
|
||
raise HTTPException(status_code=404, detail=f"1C object not found: {object_name}")
|
||
object_lineages = {object_node.lineage_id}
|
||
for edge in graph.edges.values():
|
||
if edge.source_lineage == object_node.lineage_id and edge.kind in {
|
||
EdgeKind.HAS_ATTRIBUTE,
|
||
EdgeKind.HAS_TABULAR_SECTION,
|
||
EdgeKind.HAS_FORM,
|
||
}:
|
||
object_lineages.add(edge.target_lineage)
|
||
markers = [
|
||
marker
|
||
for marker in _privacy.markers_for_project(project_id)
|
||
if marker.target_id in object_lineages
|
||
]
|
||
return ObjectPrivacyResponse(object=_named_node(object_node), markers=markers)
|
||
|
||
|
||
@app.post("/operations/jobs", response_model=OperationJob)
|
||
async def enqueue_job(job: OperationJob) -> OperationJob:
|
||
stored = _operations.enqueue(job)
|
||
return _persist_job(stored)
|
||
|
||
|
||
@app.get("/operations/jobs", response_model=list[OperationJob])
|
||
async def list_jobs(
|
||
status: OperationJobStatus | None = None,
|
||
project_id: str | None = None,
|
||
kind: str | None = None,
|
||
) -> list[OperationJob]:
|
||
jobs = _operations.list_jobs(status)
|
||
if project_id is not None:
|
||
jobs = [job for job in jobs if job.payload.get("project_id") == project_id]
|
||
if kind is not None:
|
||
jobs = [job for job in jobs if job.kind == kind]
|
||
return jobs
|
||
|
||
|
||
@app.patch("/operations/jobs/{job_id}", response_model=OperationJob)
|
||
async def update_job(job_id: str, request: JobUpdateRequest) -> OperationJob:
|
||
try:
|
||
stored = _operations.update_job(
|
||
job_id,
|
||
request.status,
|
||
result=request.result,
|
||
error=request.error,
|
||
)
|
||
return _persist_job(stored)
|
||
except KeyError as error:
|
||
raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}") from error
|
||
|
||
|
||
@app.post("/operations/jobs/{job_id}/run", response_model=OperationJob)
|
||
async def run_job(job_id: str) -> OperationJob:
|
||
job = _operations.jobs.get(job_id)
|
||
if job is None:
|
||
raise HTTPException(status_code=404, detail=f"Unknown job: {job_id}")
|
||
_persist_job(_operations.update_job(job_id, OperationJobStatus.RUNNING))
|
||
try:
|
||
result = _run_operation_job(job)
|
||
except Exception as error:
|
||
failed = _operations.update_job(job_id, OperationJobStatus.FAILED, error=str(error))
|
||
_persist_job(failed)
|
||
return failed
|
||
succeeded = _operations.update_job(job_id, OperationJobStatus.SUCCEEDED, result=result)
|
||
_persist_job(succeeded)
|
||
return succeeded
|
||
|
||
|
||
@app.post("/operations/metrics", response_model=MetricSample)
|
||
async def record_metric(metric: MetricSample) -> MetricSample:
|
||
stored = _operations.record_metric(metric)
|
||
_storage.write_document("operations_metrics", stored.metric_id, stored.model_dump(mode="json"))
|
||
return stored
|
||
|
||
|
||
@app.get("/operations/metrics", response_model=list[MetricSample])
|
||
async def list_metrics(name: str | None = None) -> list[MetricSample]:
|
||
return _operations.list_metrics(name)
|
||
|
||
|
||
@app.post("/ai/usage", response_model=AiUsageRecord)
|
||
async def record_ai_usage(usage: AiUsageRecord) -> AiUsageRecord:
|
||
used_tokens = _operations.summarize_ai_usage(user_id=usage.user_id).total_tokens
|
||
token_limit = _ai_policy.token_limit_per_day
|
||
if token_limit is not None and used_tokens + usage.total_tokens > token_limit:
|
||
raise HTTPException(
|
||
status_code=429,
|
||
detail=f"AI token limit exceeded for user {usage.user_id}: {token_limit}",
|
||
)
|
||
stored = _operations.record_ai_usage(usage)
|
||
_storage.write_document("ai_usage", stored.usage_id, stored.model_dump(mode="json"))
|
||
_operations.record_metric(
|
||
MetricSample(
|
||
metric_id=f"metric.ai_usage.{stored.usage_id}",
|
||
name="ai.tokens",
|
||
value=float(stored.total_tokens),
|
||
unit="tokens",
|
||
labels={
|
||
"project_id": stored.project_id,
|
||
"user_id": stored.user_id,
|
||
"model": stored.model,
|
||
"operation": stored.operation,
|
||
},
|
||
)
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.get("/ai/usage", response_model=list[AiUsageRecord])
|
||
async def list_ai_usage(
|
||
project_id: str | None = None,
|
||
user_id: str | None = None,
|
||
model: str | None = None,
|
||
) -> list[AiUsageRecord]:
|
||
return _operations.list_ai_usage(project_id=project_id, user_id=user_id, model=model)
|
||
|
||
|
||
@app.get("/ai/usage/summary", response_model=AiUsageSummary)
|
||
async def ai_usage_summary(
|
||
project_id: str | None = None,
|
||
user_id: str | None = None,
|
||
model: str | None = None,
|
||
operation: str | None = None,
|
||
) -> AiUsageSummary:
|
||
return _operations.summarize_ai_usage(
|
||
project_id=project_id,
|
||
user_id=user_id,
|
||
model=model,
|
||
operation=operation,
|
||
)
|
||
|
||
|
||
@app.get("/ai/policy", response_model=AiPolicyResponse)
|
||
async def ai_policy(user_id: str | None = None) -> AiPolicyResponse:
|
||
used_tokens = _operations.summarize_ai_usage(user_id=user_id).total_tokens
|
||
remaining = (
|
||
None
|
||
if _ai_policy.token_limit_per_day is None
|
||
else max(_ai_policy.token_limit_per_day - used_tokens, 0)
|
||
)
|
||
return AiPolicyResponse(policy=_ai_policy, used_tokens=used_tokens, remaining_tokens=remaining)
|
||
|
||
|
||
@app.post("/projects/{project_id}/ai/answer-policy", response_model=AiAnswerPolicyDecision)
|
||
async def ai_answer_policy(
|
||
project_id: str,
|
||
request: AiAnswerPolicyRequest,
|
||
) -> AiAnswerPolicyDecision:
|
||
_project_snapshot_or_404(project_id)
|
||
|
||
used_tokens = _operations.summarize_ai_usage(user_id=request.user_id).total_tokens
|
||
remaining = (
|
||
None
|
||
if _ai_policy.token_limit_per_day is None
|
||
else max(_ai_policy.token_limit_per_day - used_tokens, 0)
|
||
)
|
||
reasons: list[str] = []
|
||
warnings: list[str] = []
|
||
if remaining is not None and request.estimated_tokens > remaining:
|
||
reasons.append("AI token limit would be exceeded")
|
||
|
||
lineage_set = set(request.related_lineages)
|
||
related_knowledge = [
|
||
record
|
||
for record in _knowledge.list_records()
|
||
if lineage_set.intersection(record.related_lineages)
|
||
]
|
||
if not related_knowledge and request.question.strip():
|
||
related_knowledge = [
|
||
result.record
|
||
for result in _knowledge.search(request.question, limit=5)
|
||
]
|
||
if request.require_knowledge and not related_knowledge:
|
||
reasons.append("No knowledge context found for answer")
|
||
elif not related_knowledge:
|
||
warnings.append("No knowledge context found for answer")
|
||
|
||
privacy_markers = [
|
||
marker
|
||
for marker in _privacy.markers_for_project(project_id)
|
||
if marker.target_id in lineage_set
|
||
]
|
||
blocked_classifications = {PrivacyClassification.PERSONAL_DATA, PrivacyClassification.SECRET}
|
||
if (
|
||
_ai_policy.privacy_mode == PrivacyMode.LOCAL_ONLY
|
||
and not _ai_policy.allow_code_context
|
||
and any(marker.classification in blocked_classifications for marker in privacy_markers)
|
||
):
|
||
reasons.append("Privacy policy blocks sensitive 1C context")
|
||
if privacy_markers and _ai_policy.privacy_mode == PrivacyMode.LOCAL_ONLY:
|
||
warnings.append("Answer must stay inside local-only privacy mode")
|
||
if request.model and not _ai_policy.allow_external_calls and request.model.casefold().startswith("external"):
|
||
reasons.append("External AI model calls are disabled")
|
||
|
||
return AiAnswerPolicyDecision(
|
||
allowed=not reasons,
|
||
reasons=reasons,
|
||
warnings=warnings,
|
||
knowledge_records=related_knowledge[:5],
|
||
privacy_markers=privacy_markers,
|
||
used_tokens=used_tokens,
|
||
remaining_tokens=remaining,
|
||
policy=_ai_policy,
|
||
)
|
||
|
||
|
||
@app.get("/projects/{project_id}/report")
|
||
async def project_report(project_id: str) -> dict:
|
||
snapshot = _project_snapshot_or_404(project_id)
|
||
payload = build_project_report(snapshot).model_dump(mode="json")
|
||
owned_lineages = {
|
||
ownership.target_id
|
||
for ownership in _collaboration.owners_for_project(project_id)
|
||
}
|
||
unowned_objects = sorted(
|
||
node.qualified_name
|
||
for node in snapshot.nodes
|
||
if node.kind in _OWNERSHIP_TARGET_KINDS and node.lineage_id not in owned_lineages
|
||
)
|
||
privacy_markers = _privacy.markers_for_project(project_id)
|
||
sensitive_candidates = _sensitive_privacy_candidates(snapshot)
|
||
classified_lineages = {marker.target_id for marker in privacy_markers}
|
||
ai_usage = _operations.summarize_ai_usage(project_id=project_id)
|
||
payload.update(
|
||
{
|
||
"ownership_count": len(owned_lineages),
|
||
"unowned_object_count": len(unowned_objects),
|
||
"unowned_objects": unowned_objects,
|
||
"privacy_marker_count": len(privacy_markers),
|
||
"sensitive_candidate_count": len(sensitive_candidates),
|
||
"unclassified_sensitive_count": sum(
|
||
1 for node in sensitive_candidates if node.lineage_id not in classified_lineages
|
||
),
|
||
"ai_usage_request_count": ai_usage.request_count,
|
||
"ai_usage_total_tokens": ai_usage.total_tokens,
|
||
"ai_usage_cost": ai_usage.cost,
|
||
}
|
||
)
|
||
return payload
|
||
|
||
|
||
@app.post("/marketplace/packages", response_model=MarketplacePackage)
|
||
async def upsert_marketplace_package(package: MarketplacePackage) -> MarketplacePackage:
|
||
stored = _operations.upsert_marketplace_package(package)
|
||
_storage.write_document(
|
||
"marketplace_packages",
|
||
stored.package_id,
|
||
stored.model_dump(mode="json"),
|
||
)
|
||
return stored
|
||
|
||
|
||
@app.get("/marketplace/packages", response_model=list[MarketplacePackage])
|
||
async def list_marketplace_packages(enabled_only: bool = False) -> list[MarketplacePackage]:
|
||
return _operations.list_marketplace_packages(enabled_only)
|
||
|
||
|
||
@app.get("/license", response_model=LicenseState)
|
||
async def license_state() -> LicenseState:
|
||
return _operations.license_state
|
||
|
||
|
||
@app.get("/admin/summary")
|
||
async def admin_summary() -> dict:
|
||
return {
|
||
"indexed_projects": len(_snapshots),
|
||
"stored_snapshots": _storage.count_snapshots(),
|
||
"knowledge_records": len(_knowledge.list_records()),
|
||
"knowledge_packs": len(_knowledge.list_packs()),
|
||
"users": len(_collaboration.users),
|
||
"tasks": len(_collaboration.tasks),
|
||
"comments": len(_collaboration.comments),
|
||
"ownership": len(_collaboration.ownership),
|
||
"privacy_markers": len(_privacy.markers),
|
||
"ai_usage_records": len(_operations.ai_usage),
|
||
"jobs": len(_operations.jobs),
|
||
"metrics": len(_operations.metrics),
|
||
"marketplace_packages": len(_operations.marketplace),
|
||
}
|
||
|
||
|
||
def _summary(snapshot: SirSnapshot) -> SnapshotSummary:
|
||
return SnapshotSummary(
|
||
snapshot_id=snapshot.snapshot_id,
|
||
project_id=snapshot.project_id,
|
||
snapshot_hash=snapshot.snapshot_hash,
|
||
node_count=len(snapshot.nodes),
|
||
edge_count=len(snapshot.edges),
|
||
diagnostics_count=len(snapshot.diagnostics),
|
||
unresolved_references_count=len(snapshot.unresolved_references),
|
||
)
|
||
|
||
|
||
def _schema_knowledge_review(snapshot: SirSnapshot) -> list[dict]:
|
||
schema_kinds = {NodeKind.CATALOG, NodeKind.DOCUMENT, NodeKind.ATTRIBUTE, NodeKind.TABULAR_SECTION}
|
||
coverage = [
|
||
item
|
||
for item in _knowledge.coverage(snapshot)
|
||
if item.node.kind in schema_kinds and item.record_count == 0
|
||
]
|
||
return [
|
||
{
|
||
"finding_id": f"finding.knowledge.schema.uncovered.{item.node.lineage_id}",
|
||
"title": "Missing 1C schema knowledge",
|
||
"severity": DiagnosticSeverity.INFO.value,
|
||
"message": f"Schema node {item.node.qualified_name} has no related knowledge records",
|
||
"source_path": item.node.source_ref.source_path,
|
||
"line_start": item.node.source_ref.line_start,
|
||
}
|
||
for item in coverage
|
||
]
|
||
|
||
|
||
def _ownership_review(snapshot: SirSnapshot) -> list[dict]:
|
||
owned_lineages = {
|
||
ownership.target_id
|
||
for ownership in _collaboration.owners_for_project(snapshot.project_id)
|
||
}
|
||
unowned_objects = sorted(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in _OWNERSHIP_TARGET_KINDS and node.lineage_id not in owned_lineages
|
||
),
|
||
key=lambda item: item.qualified_name,
|
||
)
|
||
return [
|
||
{
|
||
"finding_id": f"finding.ownership.unassigned.{node.lineage_id}",
|
||
"title": "Missing 1C object owner",
|
||
"severity": DiagnosticSeverity.INFO.value,
|
||
"message": f"1C object {node.qualified_name} has no assigned owner",
|
||
"source_path": node.source_ref.source_path,
|
||
"line_start": node.source_ref.line_start,
|
||
}
|
||
for node in unowned_objects
|
||
]
|
||
|
||
|
||
def _privacy_review(snapshot: SirSnapshot) -> list[dict]:
|
||
classified_lineages = {
|
||
marker.target_id
|
||
for marker in _privacy.markers_for_project(snapshot.project_id)
|
||
}
|
||
return [
|
||
{
|
||
"finding_id": f"finding.privacy.unclassified.{node.lineage_id}",
|
||
"title": "Unclassified sensitive 1C field",
|
||
"severity": DiagnosticSeverity.INFO.value,
|
||
"message": f"1C field {node.qualified_name} looks sensitive but has no privacy classification",
|
||
"source_path": node.source_ref.source_path,
|
||
"line_start": node.source_ref.line_start,
|
||
}
|
||
for node in _sensitive_privacy_candidates(snapshot)
|
||
if node.lineage_id not in classified_lineages
|
||
]
|
||
|
||
|
||
def _sensitive_privacy_candidates(snapshot: SirSnapshot) -> list:
|
||
candidates = []
|
||
for node in snapshot.nodes:
|
||
if node.kind != NodeKind.ATTRIBUTE:
|
||
continue
|
||
haystack = f"{node.name} {node.qualified_name}".casefold()
|
||
if any(hint in haystack for hint in _SENSITIVE_NAME_HINTS):
|
||
candidates.append(node)
|
||
return sorted(candidates, key=lambda item: item.qualified_name)
|
||
|
||
|
||
def _index_and_store(source_path: Path, project_id: str | None, *, structure_only: bool = False) -> SirSnapshot:
|
||
snapshot = index_project(source_path, project_id=project_id, structure_only=structure_only)
|
||
graph = InMemoryProjection()
|
||
graph.project_snapshot(snapshot)
|
||
_snapshots[snapshot.project_id] = snapshot
|
||
_graphs[snapshot.project_id] = graph
|
||
_storage.save_snapshot(snapshot)
|
||
if not structure_only:
|
||
_append_and_persist_versions(snapshot)
|
||
return snapshot
|
||
|
||
|
||
def _create_mock_import_fixture(
|
||
project_id: str,
|
||
source: ImportSourceKind,
|
||
metadata: dict,
|
||
) -> Path:
|
||
root = _storage.root / "imports" / project_id
|
||
root.mkdir(parents=True, exist_ok=True)
|
||
platform_version = metadata.get("platform_version", "8.3.24")
|
||
compatibility_mode = metadata.get("compatibility_mode", "8.3.20")
|
||
(root / "metadata.xml").write_text(
|
||
f"""
|
||
<Configuration name="SFERA Mock" platformVersion="{platform_version}" compatibilityMode="{compatibility_mode}">
|
||
<CommonModule name="ОбменССайтом" qualifiedName="ОбщийМодуль.ОбменССайтом" />
|
||
<Subsystem name="Продажи" qualifiedName="Подсистема.Продажи" />
|
||
<HTTPService name="ПубличныйAPI" qualifiedName="HTTPСервис.ПубличныйAPI" />
|
||
<XDTOPackage name="ИнтеграцияCRM" qualifiedName="XDTO.ИнтеграцияCRM" />
|
||
<Catalog name="Контрагенты" qualifiedName="Справочник.Контрагенты">
|
||
<Attribute name="ИНН" qualifiedName="Справочник.Контрагенты.ИНН" />
|
||
<Attribute name="Телефон" qualifiedName="Справочник.Контрагенты.Телефон" />
|
||
<Form name="ФормаЭлемента" qualifiedName="Справочник.Контрагенты.ФормаЭлемента">
|
||
<Command name="ПроверитьИНН" qualifiedName="Справочник.Контрагенты.ФормаЭлемента.ПроверитьИНН" />
|
||
</Form>
|
||
</Catalog>
|
||
<Document name="ЗаказПокупателя" qualifiedName="Документ.ЗаказПокупателя">
|
||
<Attribute name="Контрагент" qualifiedName="Документ.ЗаказПокупателя.Контрагент" />
|
||
<TabularSection name="Товары" qualifiedName="Документ.ЗаказПокупателя.Товары">
|
||
<Attribute name="Номенклатура" qualifiedName="Документ.ЗаказПокупателя.Товары.Номенклатура" />
|
||
<Attribute name="Количество" qualifiedName="Документ.ЗаказПокупателя.Товары.Количество" />
|
||
</TabularSection>
|
||
<Form name="ФормаДокумента" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента">
|
||
<Command name="Провести" qualifiedName="Документ.ЗаказПокупателя.ФормаДокумента.Провести" />
|
||
</Form>
|
||
<Layout name="ПечатнаяФорма" qualifiedName="Документ.ЗаказПокупателя.ПечатнаяФорма" />
|
||
<Movement name="ОстаткиТоваров" qualifiedName="Документ.ЗаказПокупателя.Движения.ОстаткиТоваров" />
|
||
</Document>
|
||
<MetadataObject type="AccumulationRegister">
|
||
<Name>ОстаткиТоваров</Name>
|
||
<QualifiedName>РегистрНакопления.ОстаткиТоваров</QualifiedName>
|
||
<Dimensions>
|
||
<Name>Номенклатура</Name>
|
||
</Dimensions>
|
||
<Resources>
|
||
<Name>Количество</Name>
|
||
</Resources>
|
||
</MetadataObject>
|
||
<Report name="АнализПродаж" qualifiedName="Отчет.АнализПродаж" />
|
||
<DataProcessor name="ЗагрузкаПрайса" qualifiedName="Обработка.ЗагрузкаПрайса" />
|
||
<ExchangePlan name="ОбменСУТ" qualifiedName="ПланОбмена.ОбменСУТ" />
|
||
<Role name="МенеджерПродаж" qualifiedName="Роль.МенеджерПродаж">
|
||
<Right object="Документ.ЗаказПокупателя" read="true" write="true" post="true" />
|
||
<Right object="Справочник.Контрагенты" read="true" write="true" />
|
||
</Role>
|
||
<Extension name="{source.value}_DemoExtension" version="mock" />
|
||
</Configuration>
|
||
""".strip(),
|
||
encoding="utf-8",
|
||
)
|
||
(root / "ОбменССайтом.bsl").write_text(
|
||
"""
|
||
Процедура ВыполнитьОбмен() Экспорт
|
||
Запрос = Новый Запрос("ВЫБРАТЬ Контрагенты.Ссылка ИЗ Справочник.Контрагенты КАК Контрагенты");
|
||
Результат = Запрос.Выполнить();
|
||
КонецПроцедуры
|
||
""".strip()
|
||
+ "\n",
|
||
encoding="utf-8",
|
||
)
|
||
return root
|
||
|
||
|
||
def _source_requires_runtime(source: ImportSourceKind) -> bool:
|
||
return source in {
|
||
ImportSourceKind.CF_FILE,
|
||
ImportSourceKind.CFE_FILE,
|
||
ImportSourceKind.LIVE_INFOBASE,
|
||
ImportSourceKind.ARCHIVE_DUMP,
|
||
}
|
||
|
||
|
||
def _looks_like_metadata_dump(source_path: Path) -> bool:
|
||
if not source_path.exists() or not source_path.is_dir():
|
||
return False
|
||
for pattern in ("*.xml", "*.mdo", "*.bsl"):
|
||
try:
|
||
next(source_path.rglob(pattern))
|
||
return True
|
||
except StopIteration:
|
||
continue
|
||
return False
|
||
|
||
|
||
def _call_runtime_adapter(project_id: str, request: ImportRequest) -> dict:
|
||
url = os.environ.get("RUNTIME_ADAPTER_URL", "http://sfera-runtime-adapter:8010").rstrip("/")
|
||
payload = {
|
||
"project_id": project_id,
|
||
"source_kind": request.source.value,
|
||
"path": request.path,
|
||
"credentials_ref": request.credentials_ref,
|
||
"metadata": request.metadata,
|
||
}
|
||
data = json.dumps(payload).encode("utf-8")
|
||
http_request = urllib.request.Request(
|
||
f"{url}/runtime/import",
|
||
data=data,
|
||
method="POST",
|
||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(http_request, timeout=10) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
|
||
if os.environ.get("RUNTIME_ADAPTER_MODE", "mock") == "mock":
|
||
return {
|
||
"status": "mock_indexed",
|
||
"mode": "mock",
|
||
"platform_found": False,
|
||
"diagnostics": [f"Runtime adapter unavailable, API mock import used: {error}"],
|
||
}
|
||
raise HTTPException(status_code=503, detail=f"Runtime adapter unavailable: {error}") from error
|
||
|
||
|
||
def _import_source_infos() -> list[ImportSourceInfo]:
|
||
runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock")
|
||
return [
|
||
ImportSourceInfo(
|
||
kind=kind,
|
||
title=str(payload["title"]),
|
||
statuses=list(payload["statuses"]),
|
||
runtime_mode=runtime_mode,
|
||
available=runtime_mode != "mock" or ImportSourceStatus.REQUIRES_1C_PLATFORM not in payload["statuses"],
|
||
)
|
||
for kind, payload in _IMPORT_SOURCE_REGISTRY.items()
|
||
]
|
||
|
||
|
||
def _preflight_check(code: str, title: str, status: str, message: str) -> ImportPreflightCheck:
|
||
return ImportPreflightCheck(code=code, title=title, status=status, message=message)
|
||
|
||
|
||
def _project_settings_or_404(project_id: str) -> ProjectSettingsRequest:
|
||
if project_id not in _project_setup and not _storage.has_snapshot(project_id):
|
||
raise HTTPException(status_code=404, detail=f"Project not found: {project_id}")
|
||
return ProjectSettingsRequest.model_validate(_project_setup.get(project_id, {}).get("settings", {}))
|
||
|
||
|
||
def _agent_string_value(agent: dict, key: str) -> str:
|
||
value = agent.get(key)
|
||
return value.strip() if isinstance(value, str) else ""
|
||
|
||
|
||
def _published_value(request_value: str | None, agent: dict, *keys: str) -> str:
|
||
if request_value and request_value.strip():
|
||
return request_value.strip()
|
||
for key in keys:
|
||
value = _agent_string_value(agent, key)
|
||
if value:
|
||
return value
|
||
return ""
|
||
|
||
|
||
def _published_join_url(base_url: str, path: str) -> str:
|
||
base = base_url.strip().rstrip("/") + "/"
|
||
clean_path = path.strip() or "/odata/standard.odata"
|
||
return urljoin(base, clean_path.lstrip("/"))
|
||
|
||
|
||
def _published_base_url(server_url: str, infobase: str) -> str:
|
||
server = server_url.strip().rstrip("/")
|
||
if server and not urlsplit(server).scheme:
|
||
server = f"http://{server}"
|
||
if not infobase.strip():
|
||
return server
|
||
return _published_join_url(server, infobase.strip())
|
||
|
||
|
||
def _published_http_service_path(http_path: str, service_root: str, health_path: str) -> str:
|
||
explicit_path = http_path.strip().replace("\\", "/")
|
||
if explicit_path:
|
||
parts = [part for part in explicit_path.strip("/").split("/") if part]
|
||
if parts and parts[0].lower() == "hs":
|
||
return "/" + "/".join(parts)
|
||
return "/hs/" + "/".join(parts)
|
||
root = service_root.strip().strip("/").replace("\\", "/") or "sfera"
|
||
endpoint = health_path.strip().strip("/").replace("\\", "/") or "health"
|
||
return "/hs/" + "/".join(part for part in [root, endpoint] if part)
|
||
|
||
|
||
def _published_fetch(
|
||
url: str,
|
||
username: str,
|
||
password: str,
|
||
timeout_seconds: float,
|
||
accept: str,
|
||
max_bytes: int = 50 * 1024 * 1024,
|
||
method: str = "GET",
|
||
body: bytes | None = None,
|
||
extra_headers: dict[str, str] | None = None,
|
||
) -> tuple[int, str, bytes]:
|
||
headers = {"Accept": accept, "User-Agent": "SFERA published-infobase-check"}
|
||
if body is not None:
|
||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||
if extra_headers:
|
||
headers.update(extra_headers)
|
||
if username or password:
|
||
token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")
|
||
headers["Authorization"] = f"Basic {token}"
|
||
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||
with urllib.request.urlopen(request, timeout=max(1.0, min(timeout_seconds, 60.0))) as response:
|
||
body = response.read(max_bytes + 1)
|
||
if len(body) > max_bytes:
|
||
raise ValueError(f"Response is larger than {max_bytes // (1024 * 1024)} MB.")
|
||
return response.status, response.headers.get("content-type", ""), body
|
||
|
||
|
||
def _xml_local_name(tag: str) -> str:
|
||
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
|
||
|
||
|
||
def _parse_odata_metadata(body: bytes) -> tuple[list[PublishedInfobaseEntity], int, int, int]:
|
||
root = ET.fromstring(body)
|
||
entity_sets: list[PublishedInfobaseEntity] = []
|
||
entity_types = 0
|
||
functions = 0
|
||
actions = 0
|
||
for element in root.iter():
|
||
local_name = _xml_local_name(element.tag)
|
||
if local_name == "EntitySet":
|
||
name = element.attrib.get("Name") or element.attrib.get("name")
|
||
if name:
|
||
entity_sets.append(
|
||
PublishedInfobaseEntity(name=name, entity_type=element.attrib.get("EntityType") or element.attrib.get("entityType"))
|
||
)
|
||
elif local_name == "EntityType":
|
||
entity_types += 1
|
||
elif local_name == "Function":
|
||
functions += 1
|
||
elif local_name == "Action":
|
||
actions += 1
|
||
return entity_sets, entity_types, functions, actions
|
||
|
||
|
||
def _published_error_message(error: Exception, *, endpoint_kind: str = "publication") -> str:
|
||
if isinstance(error, urllib.error.HTTPError):
|
||
if error.code == 401:
|
||
return "Публикация требует авторизацию или логин/пароль неверные."
|
||
if error.code == 403:
|
||
return "Доступ к публикации запрещен для указанных учетных данных."
|
||
if error.code == 404:
|
||
if endpoint_kind == "sfera_extension":
|
||
return (
|
||
"HTTP-сервис SFERA не найден в публикации. Если расширение установлено, проверьте default.vrd: "
|
||
'для <httpServices> нужен атрибут publishExtensionsByDefault="true", затем перезапустите публикацию/IIS.'
|
||
)
|
||
return "Адрес публикации или OData путь не найден."
|
||
return f"HTTP {error.code}: {error.reason}"
|
||
if isinstance(error, urllib.error.URLError):
|
||
return f"Не удалось подключиться: {error.reason}"
|
||
return str(error)
|
||
|
||
|
||
def _check_published_infobase(
|
||
project_id: str,
|
||
settings: ProjectSettingsRequest,
|
||
request: PublishedInfobaseCheckRequest,
|
||
) -> PublishedInfobaseCheckResponse:
|
||
agent = settings.agent if isinstance(settings.agent, dict) else {}
|
||
server_url = _published_value(request.server_url, agent, "published_server_url", "published_server", "live_server_url")
|
||
infobase = _published_value(request.infobase, agent, "published_infobase", "live_infobase")
|
||
base_url = _published_value(request.base_url, agent, "published_base_url", "live_base_url", "live_connection")
|
||
if not base_url and server_url:
|
||
base_url = _published_base_url(server_url, infobase)
|
||
username = _published_value(request.username, agent, "published_user", "live_user")
|
||
password = _published_value(request.password, agent, "published_password", "live_password")
|
||
odata_path = _published_value(request.odata_path, agent, "published_odata_path", "odata_path") or "/odata/standard.odata"
|
||
extension_http_path_raw = _published_value(
|
||
request.extension_http_path,
|
||
agent,
|
||
"published_extension_http_path",
|
||
"sfera_extension_http_path",
|
||
)
|
||
extension_service_root = _published_value(
|
||
request.extension_service_root,
|
||
agent,
|
||
"published_extension_service_root",
|
||
"sfera_extension_service_root",
|
||
) or "sfera"
|
||
extension_health_path = _published_value(
|
||
request.extension_health_path,
|
||
agent,
|
||
"published_extension_health_path",
|
||
"sfera_extension_health_path",
|
||
) or "health"
|
||
extension_http_path = _published_http_service_path(extension_http_path_raw, extension_service_root, extension_health_path)
|
||
checks: list[ImportPreflightCheck] = []
|
||
if not base_url:
|
||
return PublishedInfobaseCheckResponse(
|
||
project_id=project_id,
|
||
status="BLOCKED",
|
||
ready=False,
|
||
checks=[
|
||
_preflight_check(
|
||
"base_url",
|
||
"URL публикации",
|
||
"BLOCKED",
|
||
"Укажите URL опубликованной базы 1С, например http://server/upo_test.",
|
||
)
|
||
],
|
||
)
|
||
if not urlsplit(base_url).scheme:
|
||
base_url = f"http://{base_url}"
|
||
odata_url = _published_join_url(base_url, odata_path)
|
||
metadata_url = _published_join_url(odata_url, "$metadata")
|
||
checks.append(_preflight_check("base_url", "URL публикации", "OK", f"Проверяем публикацию: {base_url.rstrip('/')}"))
|
||
configured_agent_id = _agent_id_for_source(settings, ImportSourceKind.CF_FILE)
|
||
can_install_extension = bool(
|
||
configured_agent_id
|
||
and (_agent_string_value(agent, "one_c_server") or server_url)
|
||
and (_agent_string_value(agent, "one_c_infobase") or infobase)
|
||
)
|
||
checks.append(
|
||
_preflight_check(
|
||
"extension_install",
|
||
"Установка расширения",
|
||
"OK" if can_install_extension else "WARNING",
|
||
f"Расширение можно устанавливать через агент {configured_agent_id}."
|
||
if can_install_extension
|
||
else "Для автоматической установки расширения нужен выбранный CF/CFE агент и параметры подключения к базе 1С.",
|
||
)
|
||
)
|
||
odata_available = True
|
||
try:
|
||
service_status, service_type, _ = _published_fetch(
|
||
odata_url,
|
||
username,
|
||
password,
|
||
request.timeout_seconds,
|
||
"application/json,application/xml,text/xml,*/*",
|
||
max_bytes=1024 * 1024,
|
||
)
|
||
checks.append(
|
||
_preflight_check(
|
||
"odata",
|
||
"OData service root",
|
||
"OK" if 200 <= service_status < 400 else "WARNING",
|
||
f"OData отвечает HTTP {service_status}. Content-Type: {service_type or 'не указан'}.",
|
||
)
|
||
)
|
||
except Exception as error:
|
||
odata_available = False
|
||
checks.append(_preflight_check("odata", "OData service root", "BLOCKED", _published_error_message(error)))
|
||
|
||
extension_url: str | None = _published_join_url(base_url, extension_http_path)
|
||
try:
|
||
extension_status, extension_type, extension_body = _published_fetch(
|
||
extension_url,
|
||
username,
|
||
password,
|
||
request.timeout_seconds,
|
||
"application/json,text/plain,application/xml,*/*",
|
||
max_bytes=1024 * 1024,
|
||
)
|
||
body_preview = extension_body[:200].decode("utf-8", errors="replace").strip()
|
||
checks.append(
|
||
_preflight_check(
|
||
"sfera_extension",
|
||
"Расширение SFERA опубликовано",
|
||
"OK" if 200 <= extension_status < 400 else "WARNING",
|
||
f"HTTP-сервис проверяется по правилу /hs/<корень>/<путь>: {extension_http_path}. "
|
||
f"Endpoint отвечает {extension_status}. Content-Type: {extension_type or 'не указан'}."
|
||
+ (f" Ответ: {body_preview}" if body_preview else ""),
|
||
)
|
||
)
|
||
except Exception as error:
|
||
checks.append(
|
||
_preflight_check(
|
||
"sfera_extension",
|
||
"Расширение SFERA опубликовано",
|
||
"WARNING",
|
||
f"HTTP-сервис проверяется по правилу /hs/<корень>/<путь>: {extension_http_path}. "
|
||
f"Endpoint не отвечает: {_published_error_message(error, endpoint_kind='sfera_extension')}",
|
||
)
|
||
)
|
||
|
||
if not odata_available:
|
||
return PublishedInfobaseCheckResponse(
|
||
project_id=project_id,
|
||
status="BLOCKED",
|
||
ready=False,
|
||
base_url=base_url.rstrip("/"),
|
||
odata_url=odata_url,
|
||
metadata_url=metadata_url,
|
||
extension_url=extension_url,
|
||
checks=checks,
|
||
)
|
||
|
||
try:
|
||
metadata_status, metadata_type, metadata_body = _published_fetch(
|
||
metadata_url,
|
||
username,
|
||
password,
|
||
request.timeout_seconds,
|
||
"application/xml,text/xml,*/*",
|
||
)
|
||
entity_sets, entity_types, functions, actions = _parse_odata_metadata(metadata_body)
|
||
checks.append(
|
||
_preflight_check(
|
||
"metadata",
|
||
"OData $metadata",
|
||
"OK" if entity_sets or entity_types else "WARNING",
|
||
f"$metadata HTTP {metadata_status}. Найдено наборов: {len(entity_sets)}, типов: {entity_types}. Content-Type: {metadata_type or 'не указан'}.",
|
||
)
|
||
)
|
||
status = "READY" if entity_sets or entity_types else "WARNING"
|
||
if any(check.status == "BLOCKED" for check in checks):
|
||
status = "BLOCKED"
|
||
elif any(check.status == "WARNING" for check in checks):
|
||
status = "WARNING"
|
||
return PublishedInfobaseCheckResponse(
|
||
project_id=project_id,
|
||
status=status,
|
||
ready=status in {"READY", "WARNING"} and bool(entity_sets or entity_types),
|
||
base_url=base_url.rstrip("/"),
|
||
odata_url=odata_url,
|
||
metadata_url=metadata_url,
|
||
extension_url=extension_url,
|
||
entity_sets_count=len(entity_sets),
|
||
entity_types_count=entity_types,
|
||
functions_count=functions,
|
||
actions_count=actions,
|
||
entity_sets=entity_sets[:25],
|
||
checks=checks,
|
||
)
|
||
except Exception as error:
|
||
checks.append(_preflight_check("metadata", "OData $metadata", "BLOCKED", _published_error_message(error)))
|
||
return PublishedInfobaseCheckResponse(
|
||
project_id=project_id,
|
||
status="BLOCKED",
|
||
ready=False,
|
||
base_url=base_url.rstrip("/"),
|
||
odata_url=odata_url,
|
||
metadata_url=metadata_url,
|
||
extension_url=extension_url,
|
||
checks=checks,
|
||
)
|
||
|
||
|
||
_SFERA_EXTENSION_MUTATION_OPERATIONS = {
|
||
"data.write",
|
||
"data.delete",
|
||
"metadata.apply",
|
||
"admin.command",
|
||
}
|
||
|
||
|
||
def _published_connection_from_settings(settings: ProjectSettingsRequest) -> dict[str, str]:
|
||
agent = settings.agent if isinstance(settings.agent, dict) else {}
|
||
server_url = _published_value(None, agent, "published_server_url", "published_server", "live_server_url")
|
||
infobase = _published_value(None, agent, "published_infobase", "live_infobase")
|
||
base_url = _published_value(None, agent, "published_base_url", "live_base_url", "live_connection")
|
||
if not base_url and server_url:
|
||
base_url = _published_base_url(server_url, infobase)
|
||
if base_url and not urlsplit(base_url).scheme:
|
||
base_url = f"http://{base_url}"
|
||
service_root = (_published_value(None, agent, "published_extension_service_root", "sfera_extension_service_root") or "sfera").strip("/")
|
||
extension_path = f"/hs/{service_root}"
|
||
return {
|
||
"base_url": base_url.rstrip("/") if base_url else "",
|
||
"username": _published_value(None, agent, "published_user", "live_user", "one_c_user", "cf_user"),
|
||
"password": _published_value(None, agent, "published_password", "live_password", "one_c_password", "cf_password"),
|
||
"extension_root_url": _published_join_url(base_url, extension_path).rstrip("/") if base_url else "",
|
||
"token": _published_value(None, agent, "sfera_extension_token", "published_extension_token"),
|
||
"allow_mutation": str(agent.get("sfera_extension_allow_mutation") is True).lower(),
|
||
}
|
||
|
||
|
||
def _sfera_extension_operation_path(operation: str) -> str:
|
||
mapping = {
|
||
"health": "health",
|
||
"metadata.snapshot": "v1/metadata",
|
||
"data.read": "v1/data/read",
|
||
"data.write": "v1/data/write",
|
||
"query.execute": "v1/query",
|
||
"metadata.apply": "v1/metadata/apply",
|
||
"admin.command": "v1/admin/command",
|
||
}
|
||
return mapping.get(operation, "v1/call")
|
||
|
||
|
||
def _call_sfera_extension(
|
||
project_id: str,
|
||
settings: ProjectSettingsRequest,
|
||
request: SferaExtensionCallRequest,
|
||
) -> SferaExtensionCallResponse:
|
||
connection = _published_connection_from_settings(settings)
|
||
checks: list[ImportPreflightCheck] = []
|
||
operation = request.operation.strip()
|
||
if not connection["base_url"]:
|
||
return SferaExtensionCallResponse(
|
||
project_id=project_id,
|
||
operation=operation,
|
||
status="BLOCKED",
|
||
checks=[_preflight_check("base_url", "URL публикации", "BLOCKED", "Заполните сервер публикации и имя базы.")],
|
||
)
|
||
checks.append(_preflight_check("base_url", "URL публикации", "OK", connection["base_url"]))
|
||
mutation_requested = operation in _SFERA_EXTENSION_MUTATION_OPERATIONS and not request.dry_run
|
||
mutation_allowed_by_project = connection["allow_mutation"] == "true"
|
||
if mutation_requested and (not request.allow_mutation or not mutation_allowed_by_project):
|
||
checks.append(
|
||
_preflight_check(
|
||
"mutation_guard",
|
||
"Защита изменения данных",
|
||
"BLOCKED",
|
||
"Операция изменения заблокирована. Включите разрешение в проекте и передайте allow_mutation=true.",
|
||
)
|
||
)
|
||
return SferaExtensionCallResponse(
|
||
project_id=project_id,
|
||
operation=operation,
|
||
status="BLOCKED",
|
||
dry_run=request.dry_run,
|
||
extension_url=connection["extension_root_url"],
|
||
checks=checks,
|
||
)
|
||
checks.append(
|
||
_preflight_check(
|
||
"mutation_guard",
|
||
"Защита изменения данных",
|
||
"OK" if not mutation_requested else "WARNING",
|
||
"Dry-run или read-only операция." if not mutation_requested else "Изменение разрешено настройками проекта и явным флагом.",
|
||
)
|
||
)
|
||
operation_path = _sfera_extension_operation_path(operation)
|
||
url = _published_join_url(connection["extension_root_url"], operation_path)
|
||
payload = {
|
||
"request_id": f"sfera-{uuid4()}",
|
||
"project_id": project_id,
|
||
"operation": operation,
|
||
"dry_run": request.dry_run,
|
||
"allow_mutation": request.allow_mutation,
|
||
"payload": request.payload,
|
||
}
|
||
headers = {"X-SFERA-Project": project_id}
|
||
if connection["token"]:
|
||
headers["X-SFERA-Token"] = connection["token"]
|
||
method = "GET" if operation == "health" else "POST"
|
||
body = None if method == "GET" else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
try:
|
||
status_code, content_type, body = _published_fetch(
|
||
url,
|
||
connection["username"],
|
||
connection["password"],
|
||
request.timeout_seconds,
|
||
"application/json,text/plain,*/*",
|
||
method=method,
|
||
body=body,
|
||
extra_headers=headers,
|
||
)
|
||
try:
|
||
result = json.loads(body.decode("utf-8-sig"))
|
||
if not isinstance(result, dict):
|
||
result = {"value": result}
|
||
except Exception:
|
||
result = {"text": body[:100_000].decode("utf-8", errors="replace")}
|
||
checks.append(
|
||
_preflight_check(
|
||
"extension_call",
|
||
"Вызов расширения SFERA",
|
||
"OK" if 200 <= status_code < 400 else "WARNING",
|
||
f"{operation} -> HTTP {status_code}. Content-Type: {content_type or 'не указан'}.",
|
||
)
|
||
)
|
||
return SferaExtensionCallResponse(
|
||
project_id=project_id,
|
||
operation=operation,
|
||
status="READY" if 200 <= status_code < 400 else "WARNING",
|
||
ready=200 <= status_code < 400,
|
||
dry_run=request.dry_run,
|
||
extension_url=url,
|
||
result=result,
|
||
checks=checks,
|
||
)
|
||
except Exception as error:
|
||
checks.append(
|
||
_preflight_check(
|
||
"extension_call",
|
||
"Вызов расширения SFERA",
|
||
"BLOCKED",
|
||
_published_error_message(error, endpoint_kind="sfera_extension"),
|
||
)
|
||
)
|
||
return SferaExtensionCallResponse(
|
||
project_id=project_id,
|
||
operation=operation,
|
||
status="BLOCKED",
|
||
ready=False,
|
||
dry_run=request.dry_run,
|
||
extension_url=url,
|
||
checks=checks,
|
||
)
|
||
|
||
|
||
def _import_check_response(project_id: str, source: ImportSourceKind, request: ImportRequest) -> ImportCheckResponse:
|
||
registry = _IMPORT_SOURCE_REGISTRY[source]
|
||
statuses = set(registry["statuses"])
|
||
runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock")
|
||
checks: list[ImportPreflightCheck] = [
|
||
_preflight_check("source", "Import source", "OK", f"{source.value}: {registry['title']}"),
|
||
]
|
||
|
||
path_required = source in {
|
||
ImportSourceKind.CF_FILE,
|
||
ImportSourceKind.CFE_FILE,
|
||
ImportSourceKind.XML_DUMP,
|
||
ImportSourceKind.EDT_PROJECT,
|
||
ImportSourceKind.ARCHIVE_DUMP,
|
||
ImportSourceKind.FILE_TREE,
|
||
ImportSourceKind.REFERENCE_CONFIGURATION,
|
||
}
|
||
if path_required:
|
||
if request.path:
|
||
path = Path(request.path)
|
||
checks.append(
|
||
_preflight_check(
|
||
"path",
|
||
"Source path",
|
||
"OK" if path.exists() else "WARNING",
|
||
f"Path {'exists' if path.exists() else 'not found, mock import can still run'}: {request.path}",
|
||
)
|
||
)
|
||
else:
|
||
checks.append(
|
||
_preflight_check(
|
||
"path",
|
||
"Source path",
|
||
"WARNING",
|
||
"Path is empty; API mock import will be used unless runtime supplies a dump.",
|
||
)
|
||
)
|
||
|
||
if ImportSourceStatus.REQUIRES_1C_PLATFORM in statuses:
|
||
checks.append(
|
||
_preflight_check(
|
||
"runtime",
|
||
"1C runtime",
|
||
"OK" if runtime_mode != "mock" else "WARNING",
|
||
f"Runtime mode is {runtime_mode}. mock mode will not execute Designer CLI.",
|
||
)
|
||
)
|
||
|
||
if ImportSourceStatus.REQUIRES_CREDENTIALS in statuses:
|
||
checks.append(
|
||
_preflight_check(
|
||
"credentials",
|
||
"Credentials",
|
||
"OK" if request.credentials_ref else "BLOCKED",
|
||
"Credentials ref configured." if request.credentials_ref else "Credentials ref is required for live infobase.",
|
||
)
|
||
)
|
||
|
||
if ImportSourceStatus.REQUIRES_AGENT in statuses:
|
||
endpoint = str(request.metadata.get("agent_endpoint") or "")
|
||
checks.append(
|
||
_preflight_check(
|
||
"agent",
|
||
"Agent endpoint",
|
||
"OK" if endpoint else "BLOCKED",
|
||
f"Agent endpoint configured: {endpoint}" if endpoint else "Agent endpoint is required for agent snapshot.",
|
||
)
|
||
)
|
||
|
||
if source == ImportSourceKind.CONTEXT_ONLY:
|
||
description = str(request.metadata.get("context_description") or "")
|
||
checks.append(
|
||
_preflight_check(
|
||
"context",
|
||
"Context description",
|
||
"OK" if description else "WARNING",
|
||
"Context description configured." if description else "Context-only import has no ecosystem description yet.",
|
||
)
|
||
)
|
||
|
||
if source == ImportSourceKind.REFERENCE_CONFIGURATION:
|
||
checks.append(
|
||
_preflight_check(
|
||
"reference",
|
||
"Reference configuration",
|
||
"OK" if request.path or request.metadata.get("context_description") else "WARNING",
|
||
"Reference configuration source configured."
|
||
if request.path or request.metadata.get("context_description")
|
||
else "Reference configuration can use a path or short description.",
|
||
)
|
||
)
|
||
|
||
blocked = any(check.status == "BLOCKED" for check in checks)
|
||
warning = any(check.status == "WARNING" for check in checks)
|
||
return ImportCheckResponse(
|
||
project_id=project_id,
|
||
source=source,
|
||
status="BLOCKED" if blocked else "WARNING" if warning else "READY",
|
||
ready=not blocked,
|
||
checks=checks,
|
||
)
|
||
|
||
|
||
def _project_setup_response(project_id: str) -> ProjectSetupResponse:
|
||
state = _project_setup.get(project_id, {})
|
||
if "import_history" not in state:
|
||
try:
|
||
state["import_history"] = _storage.read_document("project_import_history", project_id).get("items", [])
|
||
except FileNotFoundError:
|
||
pass
|
||
if "last_import" not in state and state.get("import_history"):
|
||
state["last_import"] = state["import_history"][0]
|
||
settings = ProjectSettingsRequest.model_validate(state.get("settings", {}))
|
||
status_value = state.get("status")
|
||
if status_value:
|
||
status = ProjectSetupStatus(status_value)
|
||
elif project_id in _snapshots:
|
||
status = ProjectSetupStatus.INDEXED
|
||
elif _project_has_stored_snapshot(project_id):
|
||
status = ProjectSetupStatus.INDEXED
|
||
elif settings.structure_source is None:
|
||
status = ProjectSetupStatus.NOT_CONFIGURED
|
||
else:
|
||
status = ProjectSetupStatus.IMPORT_REQUIRED
|
||
last_import = state.get("last_import")
|
||
current_source = state.get("current_source") or (last_import or {}).get("source") or (
|
||
settings.structure_source.value if settings.structure_source is not None else None
|
||
)
|
||
message = (
|
||
"Проект не проиндексирован. Выберите адаптер загрузки данных 1С."
|
||
if status in {ProjectSetupStatus.NOT_CONFIGURED, ProjectSetupStatus.IMPORT_REQUIRED}
|
||
else "Данные 1С загружены в модель SFERA. Запустите индексацию перед рабочим экраном."
|
||
if status == ProjectSetupStatus.STRUCTURE_INDEXED
|
||
else "Данные 1С загружены в модель SFERA."
|
||
)
|
||
return ProjectSetupResponse(
|
||
project_id=project_id,
|
||
status=status,
|
||
message=message,
|
||
settings=settings,
|
||
current_source=ImportSourceKind(current_source) if current_source else None,
|
||
last_import=ImportSummary.model_validate(last_import) if last_import else None,
|
||
import_history=[ImportSummary.model_validate(item) for item in state.get("import_history", [])],
|
||
import_sources=_import_source_infos(),
|
||
)
|
||
|
||
|
||
def _project_has_stored_snapshot(project_id: str) -> bool:
|
||
return _storage.has_snapshot(project_id)
|
||
|
||
|
||
async def _html5_form_data(request: Request) -> dict[str, list[str]]:
|
||
body = (await request.body()).decode("utf-8")
|
||
return parse_qs(body, keep_blank_values=True)
|
||
|
||
|
||
def _form_value(form: dict[str, list[str]], key: str) -> str | None:
|
||
values = form.get(key)
|
||
if not values:
|
||
return None
|
||
value = values[0].strip()
|
||
return value or None
|
||
|
||
|
||
def _current_import_source(project_id: str) -> ImportSourceKind:
|
||
setup = _project_setup_response(project_id)
|
||
if setup.current_source is not None:
|
||
return setup.current_source
|
||
if setup.settings.structure_source is not None:
|
||
return setup.settings.structure_source
|
||
return ImportSourceKind.XML_DUMP
|
||
|
||
|
||
def _project_summaries() -> list[ProjectSummaryResponse]:
|
||
project_ids = set(_project_setup.keys())
|
||
stored_snapshots = _storage.list_snapshot_refs()
|
||
project_ids.update(snapshot.project_id for snapshot in stored_snapshots)
|
||
snapshot_project_ids = {snapshot.project_id for snapshot in stored_snapshots}
|
||
result: list[ProjectSummaryResponse] = []
|
||
for project_id in sorted(project_ids, key=lambda item: item.lower()):
|
||
state = _project_setup.get(project_id, {})
|
||
settings = ProjectSettingsRequest.model_validate(state.get("settings", {}))
|
||
status_value = state.get("status")
|
||
if status_value:
|
||
status = ProjectSetupStatus(status_value)
|
||
elif project_id in snapshot_project_ids:
|
||
status = ProjectSetupStatus.INDEXED
|
||
elif settings.structure_source is None:
|
||
status = ProjectSetupStatus.NOT_CONFIGURED
|
||
else:
|
||
status = ProjectSetupStatus.IMPORT_REQUIRED
|
||
result.append(
|
||
ProjectSummaryResponse(
|
||
project_id=project_id,
|
||
name=settings.name or project_id,
|
||
status=status,
|
||
has_snapshot=project_id in snapshot_project_ids,
|
||
)
|
||
)
|
||
return result
|
||
|
||
|
||
def _normalize_project_id(project_id: str) -> str:
|
||
return re.sub(r"[^\w_.-]+", "-", project_id.strip(), flags=re.UNICODE).strip("-")
|
||
|
||
|
||
def _delete_project_data(project_id: str, request: ProjectDeleteRequest) -> list[str]:
|
||
deleted: list[str] = []
|
||
had_indexed_data = (
|
||
project_id in _snapshots
|
||
or project_id in _normalized_projects
|
||
or _storage.has_snapshot(project_id)
|
||
)
|
||
_project_setup.pop(project_id, None)
|
||
_snapshots.pop(project_id, None)
|
||
_graphs.pop(project_id, None)
|
||
_neo4j_projected_projects.discard(project_id)
|
||
_normalized_projects.pop(project_id, None)
|
||
if _storage.delete_snapshot(project_id):
|
||
deleted.append("snapshot")
|
||
for collection in ["project_import_history", "normalized_projects"]:
|
||
if _storage.delete_document(collection, project_id):
|
||
deleted.append(collection)
|
||
if request.delete_settings and _storage.delete_document("project_settings", project_id):
|
||
deleted.append("project_settings")
|
||
if request.delete_imports:
|
||
for folder in [
|
||
_storage.root / "imports" / _safe_storage_name(project_id),
|
||
_storage.root / "smb_imports" / _safe_storage_name(project_id),
|
||
]:
|
||
if folder.exists():
|
||
shutil.rmtree(folder)
|
||
deleted.append(folder.name)
|
||
if request.delete_versions and had_indexed_data:
|
||
count = _storage.delete_documents_matching("object_versions", lambda payload: payload.get("project_id") == project_id)
|
||
if count:
|
||
deleted.append(f"object_versions:{count}")
|
||
if request.delete_collaboration:
|
||
task_ids = {task_id for task_id, task in _collaboration.tasks.items() if task.project_id == project_id}
|
||
for task_id in task_ids:
|
||
_collaboration.tasks.pop(task_id, None)
|
||
_collaboration.sessions = {
|
||
session_id: session for session_id, session in _collaboration.sessions.items() if session.task_id not in task_ids
|
||
}
|
||
_collaboration.comments = {
|
||
comment_id: comment for comment_id, comment in _collaboration.comments.items() if comment.project_id != project_id
|
||
}
|
||
_collaboration.ownership = {
|
||
key: ownership for key, ownership in _collaboration.ownership.items() if ownership.project_id != project_id
|
||
}
|
||
_collaboration.activities = [event for event in _collaboration.activities if event.project_id != project_id]
|
||
_privacy.markers = {key: marker for key, marker in _privacy.markers.items() if marker.project_id != project_id}
|
||
for collection in [
|
||
"collaboration_comments",
|
||
"collaboration_ownership",
|
||
"collaboration_tasks",
|
||
"collaboration_sessions",
|
||
"authoring_changes",
|
||
"privacy_markers",
|
||
]:
|
||
if collection == "collaboration_sessions":
|
||
count = _storage.delete_documents_matching(collection, lambda payload: payload.get("task_id") in task_ids)
|
||
else:
|
||
count = _storage.delete_documents_matching(collection, lambda payload: payload.get("project_id") == project_id)
|
||
if count:
|
||
deleted.append(f"{collection}:{count}")
|
||
ai_usage_count = _storage.delete_documents_matching("ai_usage", lambda payload: payload.get("project_id") == project_id)
|
||
if ai_usage_count:
|
||
_operations.ai_usage = {
|
||
usage_id: usage for usage_id, usage in _operations.ai_usage.items() if usage.project_id != project_id
|
||
}
|
||
deleted.append(f"ai_usage:{ai_usage_count}")
|
||
operation_count = _storage.delete_documents_matching(
|
||
"operations_jobs",
|
||
lambda payload: payload.get("payload", {}).get("project_id") == project_id,
|
||
)
|
||
if operation_count:
|
||
_operations.jobs = {
|
||
job_id: job for job_id, job in _operations.jobs.items() if job.payload.get("project_id") != project_id
|
||
}
|
||
deleted.append(f"operations_jobs:{operation_count}")
|
||
for job_id, job in list(_agent_import_jobs.items()):
|
||
if job.project_id == project_id:
|
||
_agent_import_jobs.pop(job_id, None)
|
||
_storage.delete_document("agent_import_jobs", job_id)
|
||
deleted.append(f"agent_import_job:{job_id}")
|
||
return deleted
|
||
|
||
|
||
def _setup_status_after_import(
|
||
summary: ImportSummary,
|
||
request: ImportRequest,
|
||
snapshot: SirSnapshot | None,
|
||
) -> ProjectSetupStatus:
|
||
if request.structure_only or summary.status == "structure_indexed":
|
||
return ProjectSetupStatus.STRUCTURE_INDEXED
|
||
if summary.status in {"designer_dump_planned", "queued_for_remote_worker"}:
|
||
return ProjectSetupStatus.IMPORTED
|
||
if snapshot is not None:
|
||
return ProjectSetupStatus.INDEXED
|
||
return ProjectSetupStatus.IMPORTED
|
||
|
||
|
||
def _prepare_project_full_replace(project_id: str) -> None:
|
||
_snapshots.pop(project_id, None)
|
||
_graphs.pop(project_id, None)
|
||
_normalized_projects.pop(project_id, None)
|
||
|
||
|
||
def _materialize_import_path(
|
||
project_id: str,
|
||
request: ImportRequest,
|
||
errors: list[str],
|
||
progress: ImportProgressCallback | None = None,
|
||
) -> Path | None:
|
||
if not request.path:
|
||
return None
|
||
path = request.path.strip()
|
||
if not path.startswith("\\\\"):
|
||
return Path(path)
|
||
credentials = request.metadata.get("smb_credentials")
|
||
if not isinstance(credentials, dict):
|
||
errors.append("Для UNC/SMB пути нужны учетные данные SMB: логин и пароль.")
|
||
return None
|
||
username = str(credentials.get("username") or "").strip()
|
||
password = str(credentials.get("password") or "")
|
||
if not username or not password:
|
||
errors.append("Для UNC/SMB пути нужны учетные данные SMB: логин и пароль.")
|
||
return None
|
||
try:
|
||
target = _storage.root / "smb_imports" / _safe_storage_name(project_id)
|
||
if target.exists():
|
||
_emit_import_progress(progress, "Очистка предыдущей временной копии SMB", stage="copying")
|
||
shutil.rmtree(target)
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
_emit_import_progress(progress, f"Копирование SMB-каталога: {path}", stage="copying", bytes_copied=0, files_copied=0)
|
||
_copy_smb_tree_to_local(
|
||
source=path,
|
||
target=target,
|
||
username=username,
|
||
password=password,
|
||
domain=str(credentials.get("domain") or "") or None,
|
||
progress=progress,
|
||
)
|
||
_emit_import_progress(progress, "SMB-каталог скопирован", stage="copying_done")
|
||
return target
|
||
except Exception as error:
|
||
errors.append(f"SMB import copy failed: {error}")
|
||
return None
|
||
|
||
|
||
def _import_path_is_blocked(request: ImportRequest, errors: list[str]) -> bool:
|
||
return bool(errors and request.path and request.path.strip().startswith("\\\\"))
|
||
|
||
|
||
def _sanitize_import_metadata(metadata: dict) -> dict:
|
||
cleaned = dict(metadata)
|
||
if "smb_credentials" in cleaned:
|
||
credentials = cleaned.get("smb_credentials")
|
||
cleaned["smb_credentials"] = {
|
||
"username": credentials.get("username") if isinstance(credentials, dict) else None,
|
||
"domain": credentials.get("domain") if isinstance(credentials, dict) else None,
|
||
"password": "<redacted>",
|
||
}
|
||
for key in ("one_c_password", "password"):
|
||
if key in cleaned:
|
||
cleaned[key] = "<redacted>"
|
||
return cleaned
|
||
|
||
|
||
def _import_project_sync_preview(project_id: str, request: ImportRequest) -> ImportSummary:
|
||
errors: list[str] = []
|
||
source_path = _materialize_import_path(project_id, request, errors)
|
||
safe_metadata = _sanitize_import_metadata(request.metadata)
|
||
snapshot: SirSnapshot | None = None
|
||
next_normalized: NormalizedProject | None = None
|
||
runtime_mode = os.environ.get("RUNTIME_ADAPTER_MODE", "mock")
|
||
|
||
if _import_path_is_blocked(request, errors):
|
||
return _import_summary_from_snapshot(
|
||
project_id=project_id,
|
||
source=request.source,
|
||
status="sync_preview_blocked",
|
||
snapshot=None,
|
||
errors=errors,
|
||
metadata=safe_metadata,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=["SYNC_PREVIEW is preview-only; no project data was replaced."],
|
||
normalized=None,
|
||
mode=ImportMode.SYNC_PREVIEW,
|
||
applied=False,
|
||
sync_preview=_build_import_sync_preview(None, None),
|
||
)
|
||
|
||
if request.run_indexing and source_path is not None and source_path.exists():
|
||
snapshot = index_project(source_path, project_id=project_id, structure_only=request.structure_only)
|
||
try:
|
||
next_normalized = normalize_one_c_project(source_path, project_id=project_id)
|
||
except Exception as error:
|
||
errors.append(f"Normalized sync preview failed: {error}")
|
||
elif request.run_indexing:
|
||
if source_path is not None and not source_path.exists():
|
||
errors.append(f"Path not found, mock sync preview used: {request.path}")
|
||
mock_root = _create_mock_import_fixture(project_id, request.source, safe_metadata)
|
||
snapshot = index_project(mock_root, project_id=project_id, structure_only=request.structure_only)
|
||
next_normalized = normalize_one_c_project(mock_root, project_id=project_id)
|
||
|
||
current_normalized = _load_normalized_project(project_id)
|
||
sync_preview = _build_import_sync_preview(current_normalized, next_normalized)
|
||
summary = _import_summary_from_snapshot(
|
||
project_id=project_id,
|
||
source=request.source,
|
||
status="sync_preview",
|
||
snapshot=snapshot,
|
||
errors=errors,
|
||
metadata=safe_metadata,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=["SYNC_PREVIEW is preview-only; no project data was replaced."],
|
||
normalized=next_normalized,
|
||
mode=ImportMode.SYNC_PREVIEW,
|
||
applied=False,
|
||
sync_preview=sync_preview,
|
||
)
|
||
return summary
|
||
|
||
|
||
def _build_import_sync_preview(
|
||
current: NormalizedProject | None,
|
||
incoming: NormalizedProject | None,
|
||
) -> ImportSyncPreview:
|
||
current_index = _normalized_object_hash_index(current)
|
||
incoming_index = _normalized_object_hash_index(incoming)
|
||
items: list[ImportSyncDiffItem] = []
|
||
|
||
for qualified_name in sorted(set(current_index) | set(incoming_index)):
|
||
before = current_index.get(qualified_name)
|
||
after = incoming_index.get(qualified_name)
|
||
if before is None and after is not None:
|
||
change_kind = "ADD"
|
||
elif before is not None and after is None:
|
||
change_kind = "REMOVE"
|
||
elif before is not None and after is not None and before["hash"] != after["hash"]:
|
||
change_kind = "UPDATE"
|
||
else:
|
||
change_kind = "UNCHANGED"
|
||
|
||
source = after or before or {}
|
||
items.append(
|
||
ImportSyncDiffItem(
|
||
qualified_name=qualified_name,
|
||
name=str(source.get("name", qualified_name)),
|
||
object_kind=str(source.get("object_kind", "UNKNOWN")),
|
||
group_name=source.get("group_name"),
|
||
change_kind=change_kind,
|
||
before_hash=before["hash"] if before is not None else None,
|
||
after_hash=after["hash"] if after is not None else None,
|
||
)
|
||
)
|
||
|
||
added_count = sum(1 for item in items if item.change_kind == "ADD")
|
||
removed_count = sum(1 for item in items if item.change_kind == "REMOVE")
|
||
changed_count = sum(1 for item in items if item.change_kind == "UPDATE")
|
||
unchanged_count = sum(1 for item in items if item.change_kind == "UNCHANGED")
|
||
return ImportSyncPreview(
|
||
message=(
|
||
"Synchronization is not applied yet. This preview shows what would change; "
|
||
"use FULL_REPLACE to replace current project data."
|
||
),
|
||
added_count=added_count,
|
||
removed_count=removed_count,
|
||
changed_count=changed_count,
|
||
unchanged_count=unchanged_count,
|
||
items=[item for item in items if item.change_kind != "UNCHANGED"],
|
||
)
|
||
|
||
|
||
def _normalized_object_hash_index(normalized: NormalizedProject | None) -> dict[str, dict]:
|
||
if normalized is None:
|
||
return {}
|
||
result: dict[str, dict] = {}
|
||
for group in normalized.configuration.groups:
|
||
for item in group.objects:
|
||
payload = item.model_dump(mode="json")
|
||
result[item.qualified_name] = {
|
||
"name": item.name,
|
||
"object_kind": item.object_kind,
|
||
"group_name": group.name,
|
||
"hash": stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True)),
|
||
}
|
||
return result
|
||
|
||
|
||
def _append_import_history(project_id: str, summary: ImportSummary) -> None:
|
||
state = _project_setup.setdefault(project_id, {})
|
||
history = list(state.get("import_history", []))
|
||
history.insert(0, summary.model_dump(mode="json"))
|
||
state["import_history"] = history[:20]
|
||
_storage.write_document("project_import_history", project_id, {"project_id": project_id, "items": state["import_history"]})
|
||
|
||
|
||
def _normalize_and_store_import(project_id: str, source_path: Path) -> NormalizedProject | None:
|
||
try:
|
||
normalized = normalize_one_c_project(source_path, project_id=project_id)
|
||
except Exception:
|
||
return None
|
||
return _save_normalized_project(project_id, normalized)
|
||
|
||
|
||
def _normalized_from_runtime_result(project_id: str, runtime_result: dict) -> NormalizedProject | None:
|
||
payload = runtime_result.get("normalized_project")
|
||
if not isinstance(payload, dict):
|
||
return None
|
||
try:
|
||
normalized = NormalizedProject.model_validate(payload)
|
||
except Exception:
|
||
return None
|
||
if normalized.project_id is None:
|
||
normalized.project_id = project_id
|
||
return _save_normalized_project(project_id, normalized)
|
||
|
||
|
||
def _save_normalized_project(project_id: str, normalized: NormalizedProject) -> NormalizedProject:
|
||
_normalized_projects[project_id] = normalized
|
||
_storage.write_document("normalized_projects", project_id, normalized.model_dump(mode="json"))
|
||
return normalized
|
||
|
||
|
||
def _load_normalized_project(project_id: str) -> NormalizedProject | None:
|
||
if project_id in _normalized_projects:
|
||
return _normalized_projects[project_id]
|
||
try:
|
||
payload = _storage.read_document("normalized_projects", project_id)
|
||
except FileNotFoundError:
|
||
return None
|
||
normalized = NormalizedProject.model_validate(payload)
|
||
_normalized_projects[project_id] = normalized
|
||
return normalized
|
||
|
||
|
||
def _normalized_project_summary(normalized: NormalizedProject) -> NormalizedProjectSummary:
|
||
all_groups = _normalized_all_groups(normalized)
|
||
objects = [item for group in all_groups for item in group.objects]
|
||
return NormalizedProjectSummary(
|
||
project_id=normalized.project_id,
|
||
source_path=normalized.source_path,
|
||
group_count=len(all_groups),
|
||
object_count=len(objects),
|
||
attribute_count=sum(
|
||
len(item.attributes)
|
||
+ sum(_normalized_part_descendant_count(section, "ATTRIBUTE") for section in item.tabular_sections)
|
||
+ sum(_normalized_part_descendant_count(form, "ATTRIBUTE") for form in item.forms)
|
||
for item in objects
|
||
),
|
||
tabular_section_count=sum(len(item.tabular_sections) for item in objects),
|
||
form_count=sum(len(item.forms) for item in objects),
|
||
command_count=sum(len(item.commands) for item in objects),
|
||
role_count=sum(1 for item in objects if item.object_kind == "ROLE"),
|
||
rights_count=sum(len(item.rights) for item in objects),
|
||
module_count=sum(len(item.modules) for item in objects),
|
||
layout_count=sum(len(item.layouts) for item in objects),
|
||
movement_count=sum(len(item.movements) for item in objects),
|
||
extension_count=len(normalized.configuration.extensions),
|
||
extensions=[item.name for item in normalized.configuration.extensions],
|
||
groups=[
|
||
NormalizedGroupSummary(
|
||
name=group.name,
|
||
object_kind=", ".join(group.object_kinds),
|
||
object_count=len(group.objects),
|
||
)
|
||
for group in all_groups
|
||
],
|
||
)
|
||
|
||
|
||
def _normalized_part_descendant_count(part, kind: str | None = None) -> int:
|
||
children = getattr(part, "children", [])
|
||
return sum(
|
||
(1 if kind is None or child.kind == kind else 0) + _normalized_part_descendant_count(child, kind)
|
||
for child in children
|
||
)
|
||
|
||
|
||
def _quality_check(
|
||
code: str,
|
||
title: str,
|
||
passed: bool,
|
||
message: str,
|
||
value: int | str | None = None,
|
||
severity: str = "WARNING",
|
||
) -> ImportQualityCheck:
|
||
return ImportQualityCheck(
|
||
code=code,
|
||
title=title,
|
||
severity="INFO" if passed else severity,
|
||
passed=passed,
|
||
message=message,
|
||
value=value,
|
||
)
|
||
|
||
|
||
def _import_quality_response(project_id: str) -> ImportQualityResponse:
|
||
normalized = _load_normalized_project(project_id)
|
||
summary = _normalized_project_summary(normalized) if normalized is not None else None
|
||
state = _project_setup.get(project_id, {})
|
||
status = str(state.get("status", ProjectSetupStatus.NOT_CONFIGURED.value))
|
||
checks: list[ImportQualityCheck] = []
|
||
|
||
checks.append(
|
||
_quality_check(
|
||
"normalized_project",
|
||
"NormalizedProject",
|
||
summary is not None,
|
||
"NormalizedProject сохранен" if summary is not None else "NormalizedProject не найден",
|
||
summary.project_id if summary is not None else None,
|
||
severity="ERROR",
|
||
)
|
||
)
|
||
if summary is not None:
|
||
checks.extend(
|
||
[
|
||
_quality_check(
|
||
"metadata_groups",
|
||
"Metadata groups",
|
||
summary.group_count >= 5,
|
||
f"Найдено групп metadata: {summary.group_count}",
|
||
summary.group_count,
|
||
),
|
||
_quality_check(
|
||
"metadata_objects",
|
||
"Metadata objects",
|
||
summary.object_count > 0,
|
||
f"Найдено объектов: {summary.object_count}",
|
||
summary.object_count,
|
||
severity="ERROR",
|
||
),
|
||
_quality_check(
|
||
"forms",
|
||
"Forms",
|
||
summary.form_count > 0,
|
||
f"Найдено форм: {summary.form_count}",
|
||
summary.form_count,
|
||
),
|
||
_quality_check(
|
||
"modules",
|
||
"Modules",
|
||
summary.module_count > 0,
|
||
f"Найдено модулей: {summary.module_count}",
|
||
summary.module_count,
|
||
),
|
||
_quality_check(
|
||
"roles",
|
||
"Roles",
|
||
summary.role_count > 0,
|
||
f"Найдено ролей: {summary.role_count}",
|
||
summary.role_count,
|
||
),
|
||
_quality_check(
|
||
"rights",
|
||
"Rights",
|
||
summary.rights_count > 0,
|
||
f"Найдено прав: {summary.rights_count}",
|
||
summary.rights_count,
|
||
),
|
||
_quality_check(
|
||
"extensions",
|
||
"Extensions",
|
||
True,
|
||
f"Найдено расширений: {summary.extension_count}",
|
||
summary.extension_count,
|
||
severity="INFO",
|
||
),
|
||
]
|
||
)
|
||
|
||
weighted_checks = [check for check in checks if check.code != "extensions"]
|
||
passed = sum(1 for check in weighted_checks if check.passed)
|
||
score = round((passed / len(weighted_checks)) * 100) if weighted_checks else 0
|
||
ready_for_ide = status == ProjectSetupStatus.INDEXED.value and all(
|
||
check.passed for check in checks if check.severity == "ERROR"
|
||
)
|
||
return ImportQualityResponse(
|
||
project_id=project_id,
|
||
status=status,
|
||
score=score,
|
||
ready_for_ide=ready_for_ide,
|
||
summary=summary,
|
||
checks=checks,
|
||
)
|
||
|
||
|
||
def _normalized_object_detail(normalized: NormalizedProject, qualified_name: str) -> NormalizedObjectDetail | None:
|
||
for group in normalized.configuration.groups:
|
||
for item in group.objects:
|
||
if item.qualified_name == qualified_name:
|
||
return NormalizedObjectDetail(
|
||
project_id=normalized.project_id,
|
||
group_name=group.name,
|
||
object=item,
|
||
)
|
||
return None
|
||
|
||
|
||
def _normalized_module_sources_for_object(normalized: NormalizedProject, qualified_name: str) -> list[ModuleSourceResponse]:
|
||
normalized_query = qualified_name.strip().casefold()
|
||
if not normalized_query:
|
||
return []
|
||
selected_module = None
|
||
selected_owner = None
|
||
selected_object = None
|
||
for group in _normalized_all_groups(normalized):
|
||
for item in group.objects:
|
||
if item.qualified_name.casefold() == normalized_query or item.name.casefold() == normalized_query:
|
||
selected_object = item
|
||
break
|
||
for module in item.modules:
|
||
module_keys = {
|
||
str(module.qualified_name or "").casefold(),
|
||
str(module.name or "").casefold(),
|
||
str(module.source_path or "").casefold(),
|
||
}
|
||
if normalized_query in module_keys:
|
||
selected_module = module
|
||
selected_owner = item
|
||
break
|
||
if selected_object is not None or selected_module is not None:
|
||
break
|
||
if selected_object is not None or selected_module is not None:
|
||
break
|
||
if selected_module is not None:
|
||
return [_normalized_module_source_response(selected_module, selected_owner)]
|
||
if selected_object is None:
|
||
return []
|
||
return sorted(
|
||
[_normalized_module_source_response(module, selected_object) for module in selected_object.modules],
|
||
key=lambda item: (item.module_role, item.name),
|
||
)
|
||
|
||
|
||
def _normalized_bsl_completion_items(
|
||
normalized: NormalizedProject,
|
||
receiver: str | None,
|
||
qualified_name: str | None,
|
||
) -> list[BslCompletionItemResponse]:
|
||
receiver_key = (receiver or "").strip().casefold()
|
||
qualified_key = (qualified_name or "").strip().casefold()
|
||
items: list[BslCompletionItemResponse] = []
|
||
for group in _normalized_all_groups(normalized):
|
||
for metadata_object in group.objects:
|
||
object_names = {
|
||
metadata_object.name.casefold(),
|
||
metadata_object.qualified_name.casefold(),
|
||
}
|
||
if receiver_key and receiver_key in object_names:
|
||
items.extend(
|
||
BslCompletionItemResponse(
|
||
label=part.name,
|
||
kind=_completion_kind_for_part(part.kind),
|
||
detail=f"{metadata_object.qualified_name}: {part.kind}",
|
||
insert_text=part.name,
|
||
)
|
||
for part in [
|
||
*metadata_object.attributes,
|
||
*metadata_object.resources,
|
||
*metadata_object.dimensions,
|
||
*metadata_object.tabular_sections,
|
||
*metadata_object.commands,
|
||
]
|
||
)
|
||
for module in metadata_object.modules:
|
||
module_names = {
|
||
str(module.name or "").casefold(),
|
||
str(module.qualified_name or "").casefold(),
|
||
str(module.source_path or "").casefold(),
|
||
f"{metadata_object.name}.{module.name}".casefold(),
|
||
f"{metadata_object.qualified_name}.{module.name}".casefold(),
|
||
}
|
||
if receiver_key and receiver_key not in module_names and receiver_key not in object_names:
|
||
continue
|
||
if not receiver_key and qualified_key and qualified_key not in module_names and qualified_key not in object_names:
|
||
continue
|
||
source_text = str((module.attributes or {}).get("source_text", ""))
|
||
for routine in _normalized_module_routines(source_text):
|
||
if receiver_key and not routine.export:
|
||
continue
|
||
items.append(
|
||
BslCompletionItemResponse(
|
||
label=routine.name,
|
||
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
|
||
detail=f"{module.qualified_name or module.name}{' · Export' if routine.export else ''}",
|
||
insert_text=f"{routine.name}()",
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
def _snapshot_bsl_completion_items(snapshot: SirSnapshot, receiver: str | None) -> list[BslCompletionItemResponse]:
|
||
receiver_key = (receiver or "").strip().casefold()
|
||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||
module_lineages: set[str] = set()
|
||
if receiver_key:
|
||
for node in snapshot.nodes:
|
||
if node.kind == NodeKind.MODULE and (
|
||
node.name.casefold() == receiver_key
|
||
or node.qualified_name.casefold() == receiver_key
|
||
or str(node.attributes.get("source_path", "")).casefold() == receiver_key
|
||
):
|
||
module_lineages.add(node.lineage_id)
|
||
else:
|
||
module_lineages = {node.lineage_id for node in snapshot.nodes if node.kind == NodeKind.MODULE}
|
||
items: list[BslCompletionItemResponse] = []
|
||
for module_lineage in module_lineages:
|
||
module = nodes_by_lineage.get(module_lineage)
|
||
if module is None:
|
||
continue
|
||
for routine in _module_routines(snapshot, module_lineage, nodes_by_lineage):
|
||
if receiver_key and not routine.export:
|
||
continue
|
||
items.append(
|
||
BslCompletionItemResponse(
|
||
label=routine.name,
|
||
kind="FUNCTION" if routine.kind == "FUNCTION" else "PROCEDURE",
|
||
detail=f"{module.qualified_name}{' · Export' if routine.export else ''}",
|
||
insert_text=f"{routine.name}()",
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
def _completion_kind_for_part(kind: str) -> str:
|
||
normalized = kind.upper()
|
||
if normalized in {"ATTRIBUTE", "RESOURCE", "DIMENSION", "FIELD"}:
|
||
return "PROPERTY"
|
||
if normalized in {"COMMAND", "METHOD", "OPERATION"}:
|
||
return "METHOD"
|
||
if normalized in {"TABULAR_SECTION", "TABLE"}:
|
||
return "COLLECTION"
|
||
return "VALUE"
|
||
|
||
|
||
def _normalized_module_source_response(module, owner) -> ModuleSourceResponse:
|
||
attributes = module.attributes or {}
|
||
source_text = str(attributes.get("source_text", ""))
|
||
routines = _normalized_module_routines(source_text)
|
||
return ModuleSourceResponse(
|
||
name=module.name,
|
||
qualified_name=module.qualified_name or module.name,
|
||
module_role=str(module.module_kind or attributes.get("module_role") or "MODULE"),
|
||
source_path=module.source_path or "",
|
||
source_text=source_text,
|
||
routines_count=len(routines),
|
||
routines=routines,
|
||
)
|
||
|
||
|
||
def _normalized_module_routines(source_text: str) -> list[ModuleRoutineResponse]:
|
||
if not source_text:
|
||
return []
|
||
declarations: list[tuple[int, re.Match[str]]] = []
|
||
pattern = re.compile(r"^\s*(Процедура|Функция|Procedure|Function)\s+([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(([^)]*)\)\s*(.*)$", re.IGNORECASE | re.MULTILINE)
|
||
for match in pattern.finditer(source_text):
|
||
line_start = source_text.count("\n", 0, match.start()) + 1
|
||
declarations.append((line_start, match))
|
||
routines: list[ModuleRoutineResponse] = []
|
||
for index, (line_start, match) in enumerate(declarations):
|
||
line_end = declarations[index + 1][0] - 1 if index + 1 < len(declarations) else len(source_text.splitlines())
|
||
kind_label = match.group(1).casefold()
|
||
tail = match.group(4) or ""
|
||
routines.append(
|
||
ModuleRoutineResponse(
|
||
name=match.group(2),
|
||
kind="FUNCTION" if kind_label in {"функция", "function"} else "PROCEDURE",
|
||
line_start=line_start,
|
||
line_end=line_end,
|
||
export=bool(re.search(r"\b(Экспорт|Export)\b", tail, re.IGNORECASE)),
|
||
)
|
||
)
|
||
return routines
|
||
|
||
|
||
def _module_sources_for_object(snapshot: SirSnapshot, qualified_name: str) -> list[ModuleSourceResponse]:
|
||
nodes_by_lineage = {node.lineage_id: node for node in snapshot.nodes}
|
||
selected_routine = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in {NodeKind.PROCEDURE, NodeKind.FUNCTION}
|
||
and (node.qualified_name == qualified_name or node.name == qualified_name)
|
||
),
|
||
None,
|
||
)
|
||
if selected_routine is not None:
|
||
module_edge = next(
|
||
(
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.DECLARES and edge.target_lineage == selected_routine.lineage_id
|
||
),
|
||
None,
|
||
)
|
||
if module_edge is not None:
|
||
module = nodes_by_lineage.get(module_edge.source_lineage)
|
||
if module is not None:
|
||
return _module_source_response(snapshot, module, nodes_by_lineage)
|
||
|
||
selected_command = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind in {NodeKind.COMMAND, NodeKind.FORM, NodeKind.FORM_ELEMENT}
|
||
and (node.qualified_name == qualified_name or node.name == qualified_name)
|
||
),
|
||
None,
|
||
)
|
||
if selected_command is not None:
|
||
handler_edge = next(
|
||
(
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.HANDLES and edge.source_lineage == selected_command.lineage_id
|
||
),
|
||
None,
|
||
)
|
||
if handler_edge is not None:
|
||
routine = nodes_by_lineage.get(handler_edge.target_lineage)
|
||
module_edge = next(
|
||
(
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.DECLARES and routine is not None and edge.target_lineage == routine.lineage_id
|
||
),
|
||
None,
|
||
)
|
||
if module_edge is not None:
|
||
module = nodes_by_lineage.get(module_edge.source_lineage)
|
||
if module is not None:
|
||
return _module_source_response(snapshot, module, nodes_by_lineage)
|
||
|
||
selected_module = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.kind == NodeKind.MODULE
|
||
and (
|
||
node.qualified_name == qualified_name
|
||
or node.name == qualified_name
|
||
or node.source_ref.source_path == qualified_name
|
||
or _module_metadata_qualified_name(snapshot, node, nodes_by_lineage) == qualified_name
|
||
)
|
||
),
|
||
None,
|
||
)
|
||
if selected_module is not None:
|
||
return _module_source_response(snapshot, selected_module, nodes_by_lineage)
|
||
owner = next(
|
||
(
|
||
node
|
||
for node in snapshot.nodes
|
||
if node.qualified_name == qualified_name and node.kind in _MODULE_OWNER_NODE_KINDS
|
||
),
|
||
None,
|
||
)
|
||
if owner is None:
|
||
return []
|
||
module_edges = [
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.CONTAINS
|
||
and edge.source_lineage == owner.lineage_id
|
||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||
]
|
||
modules: list[ModuleSourceResponse] = []
|
||
for edge in module_edges:
|
||
module = nodes_by_lineage.get(edge.target_lineage)
|
||
if module is None or module.kind != NodeKind.MODULE:
|
||
continue
|
||
routines = _module_routines(snapshot, module.lineage_id, nodes_by_lineage)
|
||
modules.append(
|
||
ModuleSourceResponse(
|
||
name=module.name,
|
||
qualified_name=module.qualified_name,
|
||
module_role=str(edge.attributes.get("module_role", "MODULE")),
|
||
source_path=module.source_ref.source_path,
|
||
source_text=str(module.attributes.get("source_text", "")),
|
||
routines_count=len(routines),
|
||
routines=routines,
|
||
)
|
||
)
|
||
return sorted(modules, key=lambda item: (item.module_role, item.name))
|
||
|
||
|
||
def _module_source_response(
|
||
snapshot: SirSnapshot,
|
||
module,
|
||
nodes_by_lineage: dict[str, object],
|
||
) -> list[ModuleSourceResponse]:
|
||
owner_edge = next(
|
||
(
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.CONTAINS
|
||
and edge.target_lineage == module.lineage_id
|
||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||
),
|
||
None,
|
||
)
|
||
routines = _module_routines(snapshot, module.lineage_id, nodes_by_lineage)
|
||
return [
|
||
ModuleSourceResponse(
|
||
name=module.name,
|
||
qualified_name=module.qualified_name,
|
||
module_role=str(owner_edge.attributes.get("module_role", "MODULE")) if owner_edge else "MODULE",
|
||
source_path=module.source_ref.source_path,
|
||
source_text=str(module.attributes.get("source_text", "")),
|
||
routines_count=len(routines),
|
||
routines=routines,
|
||
)
|
||
]
|
||
|
||
|
||
def _module_metadata_qualified_name(
|
||
snapshot: SirSnapshot,
|
||
module,
|
||
nodes_by_lineage: dict[str, object],
|
||
) -> str | None:
|
||
owner_edge = next(
|
||
(
|
||
edge
|
||
for edge in snapshot.edges
|
||
if edge.kind == EdgeKind.CONTAINS
|
||
and edge.target_lineage == module.lineage_id
|
||
and edge.attributes.get("link_type") == "METADATA_MODULE"
|
||
),
|
||
None,
|
||
)
|
||
if owner_edge is None:
|
||
return None
|
||
owner = nodes_by_lineage.get(owner_edge.source_lineage)
|
||
if owner is None:
|
||
return None
|
||
role = str(owner_edge.attributes.get("module_role", "MODULE"))
|
||
form_name = str(owner_edge.attributes.get("form_name", ""))
|
||
suffix = {
|
||
"OBJECT_MODULE": "МодульОбъекта",
|
||
"MANAGER_MODULE": "МодульМенеджера",
|
||
"RECORD_SET_MODULE": "МодульНабораЗаписей",
|
||
"FORM_MODULE": f"Форма.{form_name}.Модуль" if form_name else "МодульФормы",
|
||
"MODULE": "Модуль",
|
||
}.get(role, module.name)
|
||
return f"{owner.qualified_name}.{suffix}"
|
||
|
||
|
||
def _module_routines(
|
||
snapshot: SirSnapshot,
|
||
module_lineage: str,
|
||
nodes_by_lineage: dict[str, object],
|
||
) -> list[ModuleRoutineResponse]:
|
||
routines: list[ModuleRoutineResponse] = []
|
||
for edge in snapshot.edges:
|
||
if edge.kind != EdgeKind.DECLARES or edge.source_lineage != module_lineage:
|
||
continue
|
||
routine = nodes_by_lineage.get(edge.target_lineage)
|
||
if routine is None or getattr(routine, "kind", None) not in {NodeKind.PROCEDURE, NodeKind.FUNCTION}:
|
||
continue
|
||
calls = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.CALLS)
|
||
queries = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.OWNS_QUERY)
|
||
writes = _routine_relation_values(snapshot, nodes_by_lineage, routine.lineage_id, EdgeKind.WRITES)
|
||
impact_level, impact_reasons = _routine_impact_markers(calls, queries, writes)
|
||
routines.append(
|
||
ModuleRoutineResponse(
|
||
name=routine.name,
|
||
kind=routine.kind.value,
|
||
line_start=routine.source_ref.line_start,
|
||
line_end=routine.source_ref.line_end,
|
||
export=bool(routine.attributes.get("export", False)),
|
||
calls_count=len(calls),
|
||
queries_count=len(queries),
|
||
writes_count=len(writes),
|
||
calls=calls,
|
||
queries=queries,
|
||
writes=writes,
|
||
impact_level=impact_level,
|
||
impact_reasons=impact_reasons,
|
||
)
|
||
)
|
||
return sorted(routines, key=lambda item: item.line_start or 0)
|
||
|
||
|
||
def _routine_impact_markers(calls: list[str], queries: list[str], writes: list[str]) -> tuple[str, list[str]]:
|
||
reasons: list[str] = []
|
||
if writes:
|
||
reasons.append("writes data")
|
||
if queries:
|
||
reasons.append("reads query tables")
|
||
if len(calls) >= 3:
|
||
reasons.append("fan-out calls")
|
||
if writes and (queries or len(calls) >= 2):
|
||
level = "HIGH"
|
||
elif writes or queries or len(calls) >= 3:
|
||
level = "MEDIUM"
|
||
else:
|
||
level = "LOW"
|
||
return level, reasons
|
||
|
||
|
||
def _routine_relation_values(
|
||
snapshot: SirSnapshot,
|
||
nodes_by_lineage: dict[str, object],
|
||
routine_lineage: str,
|
||
relation: EdgeKind,
|
||
) -> list[str]:
|
||
values: list[str] = []
|
||
for edge in snapshot.edges:
|
||
if edge.kind != relation or edge.source_lineage != routine_lineage:
|
||
continue
|
||
target = nodes_by_lineage.get(edge.target_lineage)
|
||
if target is None:
|
||
continue
|
||
if relation == EdgeKind.OWNS_QUERY:
|
||
query_text = str(target.attributes.get("query_text", "")).strip()
|
||
values.append(query_text or target.name)
|
||
else:
|
||
values.append(target.qualified_name or target.name)
|
||
return values
|
||
|
||
|
||
def _import_summary_from_snapshot(
|
||
*,
|
||
project_id: str,
|
||
source: ImportSourceKind,
|
||
status: str,
|
||
snapshot: SirSnapshot | None,
|
||
errors: list[str],
|
||
metadata: dict,
|
||
runtime_mode: str,
|
||
runtime_diagnostics: list[str],
|
||
normalized: NormalizedProject | None,
|
||
mode: ImportMode = ImportMode.FULL_REPLACE,
|
||
applied: bool = True,
|
||
sync_preview: ImportSyncPreview | None = None,
|
||
) -> ImportSummary:
|
||
normalized_summary = _normalized_project_summary(normalized) if normalized is not None else None
|
||
empty_counts = snapshot is None and normalized_summary is None and not applied
|
||
if snapshot is None:
|
||
return ImportSummary(
|
||
source=source,
|
||
mode=mode,
|
||
applied=applied,
|
||
status=status,
|
||
last_import=_current_timestamp(),
|
||
source_path=None,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=runtime_diagnostics,
|
||
errors=errors,
|
||
diagnostics_count=0,
|
||
diagnostics=[],
|
||
object_count=normalized_summary.object_count if normalized_summary is not None else 0 if empty_counts else 12,
|
||
module_count=normalized_summary.module_count if normalized_summary is not None else 0 if empty_counts else 4,
|
||
form_count=normalized_summary.form_count if normalized_summary is not None else 0 if empty_counts else 3,
|
||
role_count=normalized_summary.role_count if normalized_summary is not None else 0 if empty_counts else 2,
|
||
extensions=(
|
||
normalized_summary.extensions
|
||
if normalized_summary is not None
|
||
else []
|
||
if empty_counts
|
||
else list(metadata.get("extensions", ["DemoExtension"]))
|
||
),
|
||
platform_version=metadata.get("platform_version", "mock-8.3"),
|
||
compatibility_mode=metadata.get("compatibility_mode", "mock"),
|
||
normalized_summary=normalized_summary,
|
||
sync_preview=sync_preview,
|
||
)
|
||
object_kinds = {
|
||
NodeKind.CATALOG,
|
||
NodeKind.DOCUMENT,
|
||
NodeKind.REGISTER,
|
||
NodeKind.REPORT,
|
||
NodeKind.DATA_PROCESSOR,
|
||
NodeKind.COMMON_MODULE,
|
||
NodeKind.EXCHANGE_PLAN,
|
||
NodeKind.BUSINESS_PROCESS,
|
||
NodeKind.TASK,
|
||
NodeKind.SUBSYSTEM,
|
||
NodeKind.HTTP_SERVICE,
|
||
NodeKind.XDTO_PACKAGE,
|
||
NodeKind.EXTENSION,
|
||
}
|
||
return ImportSummary(
|
||
source=source,
|
||
mode=mode,
|
||
applied=applied,
|
||
status=status,
|
||
last_import=_current_timestamp(),
|
||
source_path=snapshot.metadata.source_root,
|
||
runtime_mode=runtime_mode,
|
||
runtime_diagnostics=runtime_diagnostics,
|
||
errors=errors,
|
||
diagnostics_count=len(snapshot.diagnostics),
|
||
diagnostics=[
|
||
f"{diagnostic.severity.value} {diagnostic.code}: {diagnostic.message}"
|
||
for diagnostic in snapshot.diagnostics[:20]
|
||
],
|
||
object_count=normalized_summary.object_count
|
||
if normalized_summary is not None
|
||
else sum(1 for node in snapshot.nodes if node.kind in object_kinds),
|
||
module_count=normalized_summary.module_count
|
||
if normalized_summary is not None
|
||
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.MODULE),
|
||
form_count=normalized_summary.form_count
|
||
if normalized_summary is not None
|
||
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.FORM),
|
||
role_count=normalized_summary.role_count
|
||
if normalized_summary is not None
|
||
else sum(1 for node in snapshot.nodes if node.kind == NodeKind.ROLE),
|
||
extensions=normalized_summary.extensions if normalized_summary is not None else list(metadata.get("extensions", [])),
|
||
platform_version=metadata.get("platform_version"),
|
||
compatibility_mode=metadata.get("compatibility_mode"),
|
||
snapshot=_summary(snapshot),
|
||
normalized_summary=normalized_summary,
|
||
sync_preview=sync_preview,
|
||
)
|
||
|
||
|
||
def _current_timestamp() -> str:
|
||
from datetime import datetime, timezone
|
||
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _repo_root() -> Path:
|
||
return Path(__file__).resolve().parents[4]
|
||
|
||
|
||
def _windows_agent_script_path() -> Path:
|
||
path = _repo_root() / "scripts" / "windows-agent" / "sfera-windows-agent.ps1"
|
||
if not path.exists():
|
||
raise HTTPException(status_code=404, detail=f"Windows agent script not found: {path}")
|
||
return path
|
||
|
||
|
||
def _build_sfera_extension_package() -> bytes:
|
||
source_root = _repo_root() / "integrations" / "1c" / "sfera-extension"
|
||
if not source_root.exists():
|
||
raise HTTPException(status_code=404, detail=f"SFERA extension sources not found: {source_root}")
|
||
from io import BytesIO
|
||
|
||
buffer = BytesIO()
|
||
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||
for path in sorted(source_root.rglob("*")):
|
||
if not path.is_file():
|
||
continue
|
||
archive.write(path, path.relative_to(source_root).as_posix())
|
||
return buffer.getvalue()
|
||
|
||
|
||
def _file_sha256(path: Path) -> str:
|
||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||
|
||
|
||
def _public_origin(request: Request) -> str:
|
||
forwarded = request.headers.get("x-sfera-public-origin")
|
||
if forwarded:
|
||
return forwarded.rstrip("/")
|
||
return str(request.base_url).rstrip("/")
|
||
|
||
|
||
def _public_api_origin(request: Request) -> str:
|
||
configured = os.environ.get("SFERA_PUBLIC_API_URL")
|
||
if configured:
|
||
return configured.rstrip("/")
|
||
origin = _public_origin(request)
|
||
try:
|
||
parsed = urlsplit(origin)
|
||
if parsed.port == 49230:
|
||
netloc = f"{parsed.hostname}:49280"
|
||
return urlunsplit((parsed.scheme, netloc, "", "", "")).rstrip("/")
|
||
except ValueError:
|
||
pass
|
||
return origin
|
||
|
||
|
||
def _load_snapshot_into_memory(project_id: str) -> SirSnapshot:
|
||
snapshot = _storage.load_snapshot(project_id)
|
||
graph = InMemoryProjection()
|
||
graph.project_snapshot(snapshot)
|
||
_snapshots[snapshot.project_id] = snapshot
|
||
_graphs[snapshot.project_id] = graph
|
||
if len(snapshot.nodes) <= 5000:
|
||
_append_and_persist_versions(snapshot)
|
||
return snapshot
|
||
|
||
|
||
def _snapshot_and_graph(project_id: str) -> tuple[SirSnapshot, InMemoryProjection]:
|
||
snapshot = _snapshots.get(project_id)
|
||
graph = _graphs.get(project_id)
|
||
if snapshot is None or graph is None:
|
||
try:
|
||
snapshot = _load_snapshot_into_memory(project_id)
|
||
except FileNotFoundError as error:
|
||
raise HTTPException(status_code=404, detail=f"Project is not indexed: {project_id}") from error
|
||
graph = _graphs[project_id]
|
||
return snapshot, graph
|
||
|
||
|
||
def _find_graph_node(graph: InMemoryProjection, name: str, kinds: set[NodeKind]):
|
||
wanted = name.lower()
|
||
return next(
|
||
(
|
||
node
|
||
for node in graph.nodes.values()
|
||
if node.kind in kinds and (node.name.lower() == wanted or node.qualified_name.lower() == wanted)
|
||
),
|
||
None,
|
||
)
|
||
|
||
|
||
def _object_access_grants(graph: InMemoryProjection, object_node) -> list[tuple]:
|
||
grants = []
|
||
for edge in graph.edges.values():
|
||
if edge.kind != EdgeKind.GRANTS_ACCESS or edge.target_lineage != object_node.lineage_id:
|
||
continue
|
||
role = graph.nodes.get(edge.source_lineage)
|
||
if role is not None and role.kind == NodeKind.ROLE:
|
||
grants.append((role, edge))
|
||
return sorted(grants, key=lambda item: item[0].qualified_name)
|
||
|
||
|
||
def _role_object_grants(graph: InMemoryProjection, role) -> list[tuple]:
|
||
grants = []
|
||
for edge in graph.edges.values():
|
||
if edge.kind != EdgeKind.GRANTS_ACCESS or edge.source_lineage != role.lineage_id:
|
||
continue
|
||
target = graph.nodes.get(edge.target_lineage)
|
||
if target is not None and target.kind in _ACCESS_TARGET_KINDS:
|
||
grants.append((target, edge))
|
||
return sorted(grants, key=lambda item: item[0].qualified_name)
|
||
|
||
|
||
async def _project_snapshot_to_neo4j(project_id: str, snapshot: SirSnapshot) -> dict[str, int]:
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
projection = Neo4jProjection(driver)
|
||
await projection.clear_project(project_id)
|
||
await projection.project_snapshot(snapshot)
|
||
return await projection.counts(project_id)
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _apply_delta_to_neo4j(project_id: str, delta: SirDelta) -> dict[str, int]:
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
projection = Neo4jProjection(driver)
|
||
await projection.apply_delta(delta, project_id=project_id)
|
||
return await projection.counts(project_id)
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
def _run_operation_job(job: OperationJob) -> dict:
|
||
if job.kind == "INDEX_PROJECT":
|
||
path = job.payload.get("path")
|
||
if not path:
|
||
raise ValueError("INDEX_PROJECT job requires payload.path")
|
||
source_path = Path(path)
|
||
if not source_path.exists():
|
||
raise FileNotFoundError(path)
|
||
snapshot = _index_and_store(source_path, job.payload.get("project_id"))
|
||
return {"snapshot": _summary(snapshot).model_dump(mode="json")}
|
||
if job.kind == "LOAD_PROJECT":
|
||
project_id = job.payload.get("project_id")
|
||
if not project_id:
|
||
raise ValueError("LOAD_PROJECT job requires payload.project_id")
|
||
snapshot = _load_snapshot_into_memory(project_id)
|
||
return {"snapshot": _summary(snapshot).model_dump(mode="json")}
|
||
raise ValueError(f"Unsupported job kind: {job.kind}")
|
||
|
||
|
||
async def _neo4j_routine_query(project_id: str, routine_name: str, *, outgoing: bool) -> list[dict]:
|
||
pattern = (
|
||
"MATCH (source:SferaNode {project_id: $project_id, name: $routine_name})"
|
||
"-[r:SEMANTIC_EDGE {kind: 'CALLS'}]->"
|
||
"(target:SferaNode {project_id: $project_id})"
|
||
if outgoing
|
||
else "MATCH (source:SferaNode {project_id: $project_id})"
|
||
"-[r:SEMANTIC_EDGE {kind: 'CALLS'}]->"
|
||
"(target:SferaNode {project_id: $project_id, name: $routine_name})"
|
||
)
|
||
return_node = "target" if outgoing else "source"
|
||
query = f"""
|
||
{pattern}
|
||
RETURN {return_node}.lineage_id AS lineage_id,
|
||
{return_node}.kind AS kind,
|
||
{return_node}.name AS name,
|
||
{return_node}.qualified_name AS qualified_name
|
||
ORDER BY qualified_name
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(
|
||
query,
|
||
project_id=project_id,
|
||
routine_name=routine_name,
|
||
)
|
||
return [dict(record) async for record in result]
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_relation_query(project_id: str, routine_name: str, *, relation: str) -> list[dict]:
|
||
if relation == "READS_TABLE":
|
||
query = """
|
||
MATCH (routine:SferaNode {project_id: $project_id, name: $routine_name})
|
||
-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]->
|
||
(query:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]->
|
||
(target:SferaNode {project_id: $project_id})
|
||
RETURN target.lineage_id AS lineage_id,
|
||
target.kind AS kind,
|
||
target.name AS name,
|
||
target.qualified_name AS qualified_name
|
||
ORDER BY qualified_name
|
||
"""
|
||
elif relation == "WRITES":
|
||
query = """
|
||
MATCH (routine:SferaNode {project_id: $project_id, name: $routine_name})
|
||
-[:SEMANTIC_EDGE {kind: 'WRITES'}]->
|
||
(target:SferaNode {project_id: $project_id})
|
||
RETURN target.lineage_id AS lineage_id,
|
||
target.kind AS kind,
|
||
target.name AS name,
|
||
target.qualified_name AS qualified_name
|
||
ORDER BY qualified_name
|
||
"""
|
||
else:
|
||
raise ValueError(f"Unsupported Neo4j relation query: {relation}")
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(
|
||
query,
|
||
project_id=project_id,
|
||
routine_name=routine_name,
|
||
)
|
||
return [dict(record) async for record in result]
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_integrations_query(project_id: str, *, kind: str | None = None) -> list[dict]:
|
||
query = """
|
||
MATCH (endpoint:SferaNode {project_id: $project_id, kind: 'INTEGRATION_ENDPOINT'})
|
||
WHERE $kind IS NULL OR endpoint.attributes_json CONTAINS $kind
|
||
OPTIONAL MATCH (module:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'USES_INTEGRATION'}]->(endpoint)
|
||
RETURN endpoint.lineage_id AS endpoint_id,
|
||
endpoint.name AS name,
|
||
endpoint.attributes_json AS attributes_json,
|
||
module.qualified_name AS owner
|
||
ORDER BY name
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, kind=kind)
|
||
rows = []
|
||
async for record in result:
|
||
attributes = json.loads(record["attributes_json"] or "{}")
|
||
rows.append(
|
||
{
|
||
"endpoint_id": record["endpoint_id"],
|
||
"name": record["name"],
|
||
"kind": attributes.get("integration_kind", "UNKNOWN"),
|
||
"direction": attributes.get("direction", "UNKNOWN"),
|
||
"owner": record["owner"],
|
||
"attributes": attributes,
|
||
}
|
||
)
|
||
return rows
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_integration_modules_query(project_id: str, integration_name: str) -> list[dict]:
|
||
query = """
|
||
MATCH (module:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'USES_INTEGRATION'}]->
|
||
(endpoint:SferaNode {project_id: $project_id, kind: 'INTEGRATION_ENDPOINT'})
|
||
WHERE endpoint.name = $integration_name
|
||
OR endpoint.qualified_name = $integration_name
|
||
OR endpoint.attributes_json CONTAINS $integration_name
|
||
RETURN module.lineage_id AS lineage_id,
|
||
module.kind AS kind,
|
||
module.name AS name,
|
||
module.qualified_name AS qualified_name
|
||
ORDER BY qualified_name
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, integration_name=integration_name)
|
||
return [dict(record) async for record in result]
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_impact_query(project_id: str, object_name: str) -> dict | None:
|
||
query = """
|
||
MATCH (object:SferaNode {project_id: $project_id})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'CONTAINS'}]->
|
||
(module:SferaNode {project_id: $project_id, kind: 'MODULE'})
|
||
OPTIONAL MATCH (module)-[:SEMANTIC_EDGE {kind: 'DECLARES'}]->
|
||
(routine:SferaNode {project_id: $project_id})
|
||
WHERE routine.kind IN ['PROCEDURE', 'FUNCTION'] OR routine IS NULL
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'RUNS'}]->
|
||
(jobRoutine:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_FORM'}]->
|
||
(form:SferaNode {project_id: $project_id, kind: 'FORM'})
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]->
|
||
(attribute:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'})
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_TABULAR_SECTION'}]->
|
||
(tabularSection:SferaNode {project_id: $project_id, kind: 'TABULAR_SECTION'})
|
||
OPTIONAL MATCH (tabularSection)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]->
|
||
(tabularColumn:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'})
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]->
|
||
(objectCommand:SferaNode {project_id: $project_id, kind: 'COMMAND'})
|
||
OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]->
|
||
(formCommand:SferaNode {project_id: $project_id, kind: 'COMMAND'})
|
||
OPTIONAL MATCH (objectCommand)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]->
|
||
(objectCommandHandler:SferaNode {project_id: $project_id})
|
||
WHERE objectCommandHandler.kind IN ['PROCEDURE', 'FUNCTION'] OR objectCommandHandler IS NULL
|
||
OPTIONAL MATCH (formCommand)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]->
|
||
(formCommandHandler:SferaNode {project_id: $project_id})
|
||
WHERE formCommandHandler.kind IN ['PROCEDURE', 'FUNCTION'] OR formCommandHandler IS NULL
|
||
OPTIONAL MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'})
|
||
-[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]->
|
||
(object)
|
||
OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'CALLS'}]->
|
||
(callee:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]->
|
||
(:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]->
|
||
(table:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (routine)-[:SEMANTIC_EDGE {kind: 'WRITES'}]->
|
||
(write:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'CALLS'}]->
|
||
(objectHandlerCallee:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'CALLS'}]->
|
||
(formHandlerCallee:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]->
|
||
(:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]->
|
||
(objectHandlerTable:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'OWNS_QUERY'}]->
|
||
(:SferaNode {project_id: $project_id})
|
||
-[:SEMANTIC_EDGE {kind: 'READS_TABLE'}]->
|
||
(formHandlerTable:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (objectCommandHandler)-[:SEMANTIC_EDGE {kind: 'WRITES'}]->
|
||
(objectHandlerWrite:SferaNode {project_id: $project_id})
|
||
OPTIONAL MATCH (formCommandHandler)-[:SEMANTIC_EDGE {kind: 'WRITES'}]->
|
||
(formHandlerWrite:SferaNode {project_id: $project_id})
|
||
RETURN object,
|
||
collect(DISTINCT module) AS modules,
|
||
collect(DISTINCT routine) + collect(DISTINCT jobRoutine) +
|
||
collect(DISTINCT objectCommandHandler) + collect(DISTINCT formCommandHandler) AS routines,
|
||
collect(DISTINCT form) AS forms,
|
||
collect(DISTINCT objectCommand) + collect(DISTINCT formCommand) AS commands,
|
||
collect(DISTINCT attribute) AS attributes,
|
||
collect(DISTINCT tabularSection) AS tabular_sections,
|
||
collect(DISTINCT {section: tabularSection, column: tabularColumn}) AS tabular_section_columns,
|
||
collect(DISTINCT role) AS roles,
|
||
collect(DISTINCT {role: role, attributes_json: access.attributes_json}) AS role_access,
|
||
CASE WHEN object.kind = 'SCHEDULED_JOB' THEN [object] ELSE [] END AS jobs,
|
||
collect(DISTINCT callee) + collect(DISTINCT objectHandlerCallee) +
|
||
collect(DISTINCT formHandlerCallee) AS callees,
|
||
collect(DISTINCT table) + collect(DISTINCT objectHandlerTable) +
|
||
collect(DISTINCT formHandlerTable) AS query_tables,
|
||
collect(DISTINCT write) + collect(DISTINCT objectHandlerWrite) +
|
||
collect(DISTINCT formHandlerWrite) AS writes
|
||
ORDER BY object.qualified_name
|
||
LIMIT 1
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
record = await result.single()
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"object": _neo4j_node_to_named(record["object"]),
|
||
"modules": _neo4j_nodes_to_named(record["modules"]),
|
||
"routines": _neo4j_nodes_to_named(record["routines"]),
|
||
"forms": _neo4j_nodes_to_named(record["forms"]),
|
||
"commands": _neo4j_nodes_to_named(record["commands"]),
|
||
"attributes": _neo4j_nodes_to_named(record["attributes"]),
|
||
"tabular_sections": _neo4j_nodes_to_named(record["tabular_sections"]),
|
||
"tabular_section_columns": _neo4j_tabular_section_columns_to_named(
|
||
record["tabular_section_columns"]
|
||
),
|
||
"roles": _neo4j_nodes_to_named(record["roles"]),
|
||
"role_access": _neo4j_role_access_to_named(record["role_access"]),
|
||
"jobs": _neo4j_nodes_to_named(record["jobs"]),
|
||
"callees": _neo4j_nodes_to_named(record["callees"]),
|
||
"query_tables": _neo4j_nodes_to_named(record["query_tables"]),
|
||
"writes": _neo4j_nodes_to_named(record["writes"]),
|
||
}
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_attributes_query(project_id: str, object_name: str) -> list[dict]:
|
||
return await _neo4j_object_children_query(
|
||
project_id,
|
||
object_name,
|
||
edge_kind="HAS_ATTRIBUTE",
|
||
child_kind="ATTRIBUTE",
|
||
)
|
||
|
||
|
||
async def _neo4j_object_schema_query(project_id: str, object_name: str) -> dict | None:
|
||
object_node = await _neo4j_object_query(project_id, object_name)
|
||
if object_node is None:
|
||
return None
|
||
return {
|
||
"object": object_node,
|
||
"attributes": await _neo4j_object_attributes_query(project_id, object_name),
|
||
"tabular_sections": await _neo4j_object_tabular_section_columns_query(project_id, object_name),
|
||
}
|
||
|
||
|
||
async def _neo4j_object_query(project_id: str, object_name: str) -> dict | None:
|
||
query = """
|
||
MATCH (object:SferaNode {project_id: $project_id})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
RETURN object
|
||
ORDER BY object.qualified_name
|
||
LIMIT 1
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
record = await result.single()
|
||
if record is None:
|
||
return None
|
||
return _neo4j_node_to_named(record["object"])
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_tabular_sections_query(project_id: str, object_name: str) -> list[dict]:
|
||
return await _neo4j_object_children_query(
|
||
project_id,
|
||
object_name,
|
||
edge_kind="HAS_TABULAR_SECTION",
|
||
child_kind="TABULAR_SECTION",
|
||
)
|
||
|
||
|
||
async def _neo4j_object_tabular_section_columns_query(project_id: str, object_name: str) -> list[dict]:
|
||
query = """
|
||
MATCH (object:SferaNode {project_id: $project_id})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_TABULAR_SECTION'}]->
|
||
(section:SferaNode {project_id: $project_id, kind: 'TABULAR_SECTION'})
|
||
OPTIONAL MATCH (section)-[:SEMANTIC_EDGE {kind: 'HAS_ATTRIBUTE'}]->
|
||
(column:SferaNode {project_id: $project_id, kind: 'ATTRIBUTE'})
|
||
RETURN section,
|
||
collect(DISTINCT column) AS columns
|
||
ORDER BY section.qualified_name
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
return [
|
||
{
|
||
"tabular_section": _neo4j_node_to_named(record["section"]),
|
||
"columns": _neo4j_nodes_to_named(record["columns"]),
|
||
}
|
||
async for record in result
|
||
]
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_children_query(
|
||
project_id: str,
|
||
object_name: str,
|
||
*,
|
||
edge_kind: str,
|
||
child_kind: str,
|
||
) -> list[dict]:
|
||
query = f"""
|
||
MATCH (object:SferaNode {{project_id: $project_id}})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
MATCH (object)-[:SEMANTIC_EDGE {{kind: '{edge_kind}'}}]->
|
||
(child:SferaNode {{project_id: $project_id, kind: '{child_kind}'}})
|
||
RETURN child.lineage_id AS lineage_id,
|
||
child.kind AS kind,
|
||
child.name AS name,
|
||
child.qualified_name AS qualified_name
|
||
ORDER BY qualified_name
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
return [dict(record) async for record in result]
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_ui_query(project_id: str, object_name: str) -> dict | None:
|
||
query = """
|
||
MATCH (object:SferaNode {project_id: $project_id})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
OPTIONAL MATCH (object)-[:SEMANTIC_EDGE {kind: 'HAS_FORM'}]->
|
||
(form:SferaNode {project_id: $project_id, kind: 'FORM'})
|
||
OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_COMMAND'}]->
|
||
(command:SferaNode {project_id: $project_id, kind: 'COMMAND'})
|
||
OPTIONAL MATCH (command)-[:SEMANTIC_EDGE {kind: 'HANDLES'}]->
|
||
(handler:SferaNode {project_id: $project_id})
|
||
WHERE handler.kind IN ['PROCEDURE', 'FUNCTION'] OR handler IS NULL
|
||
OPTIONAL MATCH (form)-[:SEMANTIC_EDGE {kind: 'HAS_ELEMENT'}]->
|
||
(element:SferaNode {project_id: $project_id, kind: 'FORM_ELEMENT'})
|
||
RETURN object,
|
||
collect(DISTINCT form) AS forms,
|
||
collect(DISTINCT {form: form, command: command, handler: handler}) AS command_links,
|
||
collect(DISTINCT {form: form, element: element}) AS element_links
|
||
ORDER BY object.qualified_name
|
||
LIMIT 1
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(
|
||
_neo4j_uri,
|
||
auth=(_neo4j_user, _neo4j_password),
|
||
) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
record = await result.single()
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"object": _neo4j_node_to_named(record["object"]),
|
||
"forms": _neo4j_object_ui_forms(
|
||
record["forms"],
|
||
record["command_links"],
|
||
record["element_links"],
|
||
),
|
||
}
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_object_access_query(project_id: str, object_name: str) -> dict | None:
|
||
query = """
|
||
MATCH (object:SferaNode {project_id: $project_id})
|
||
WHERE object.name = $object_name OR object.qualified_name = $object_name
|
||
OPTIONAL MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'})
|
||
-[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]->(object)
|
||
RETURN object,
|
||
collect(DISTINCT {role: role, attributes_json: access.attributes_json}) AS grants
|
||
ORDER BY object.qualified_name
|
||
LIMIT 1
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, object_name=object_name)
|
||
record = await result.single()
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"object": _neo4j_node_to_named(record["object"]),
|
||
"grants": _neo4j_role_access_to_named(record["grants"]),
|
||
}
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
async def _neo4j_role_access_query(project_id: str, role_name: str) -> dict | None:
|
||
query = """
|
||
MATCH (role:SferaNode {project_id: $project_id, kind: 'ROLE'})
|
||
WHERE role.name = $role_name OR role.qualified_name = $role_name
|
||
OPTIONAL MATCH (role)-[access:SEMANTIC_EDGE {kind: 'GRANTS_ACCESS'}]->
|
||
(object:SferaNode {project_id: $project_id})
|
||
RETURN role,
|
||
collect(DISTINCT object) AS objects,
|
||
collect(DISTINCT {object: object, attributes_json: access.attributes_json}) AS grants
|
||
ORDER BY role.qualified_name
|
||
LIMIT 1
|
||
"""
|
||
try:
|
||
async with AsyncGraphDatabase.driver(_neo4j_uri, auth=(_neo4j_user, _neo4j_password)) as driver:
|
||
await driver.verify_connectivity()
|
||
async with driver.session() as session:
|
||
result = await session.run(query, project_id=project_id, role_name=role_name)
|
||
record = await result.single()
|
||
if record is None:
|
||
return None
|
||
return {
|
||
"role": _neo4j_node_to_named(record["role"]),
|
||
"objects": _neo4j_nodes_to_named(record["objects"]),
|
||
"grants": _neo4j_object_access_to_named(record["grants"]),
|
||
}
|
||
except Exception as error:
|
||
raise HTTPException(status_code=503, detail=f"Neo4j unavailable: {error}") from error
|
||
|
||
|
||
def _neo4j_nodes_to_named(nodes: list) -> list[dict]:
|
||
seen: dict[str, dict] = {}
|
||
for node in nodes:
|
||
if node is None:
|
||
continue
|
||
named = _neo4j_node_to_named(node)
|
||
seen.setdefault(named["lineage_id"], named)
|
||
return sorted(seen.values(), key=lambda row: row["qualified_name"])
|
||
|
||
|
||
def _neo4j_object_ui_forms(forms: list, command_links: list, element_links: list) -> list[dict]:
|
||
by_lineage: dict[str, dict] = {}
|
||
for form in forms:
|
||
if form is None:
|
||
continue
|
||
named_form = _neo4j_node_to_named(form)
|
||
by_lineage[named_form["lineage_id"]] = {
|
||
"form": named_form,
|
||
"commands": [],
|
||
"elements": [],
|
||
"command_handlers": {},
|
||
}
|
||
command_seen: set[tuple[str, str]] = set()
|
||
for link in command_links:
|
||
if not link or link.get("form") is None or link.get("command") is None:
|
||
continue
|
||
form = _neo4j_node_to_named(link["form"])
|
||
command = _neo4j_node_to_named(link["command"])
|
||
bucket = by_lineage.setdefault(
|
||
form["lineage_id"],
|
||
{"form": form, "commands": [], "elements": [], "command_handlers": {}},
|
||
)
|
||
command_key = (form["lineage_id"], command["lineage_id"])
|
||
if command_key not in command_seen:
|
||
command_seen.add(command_key)
|
||
bucket["commands"].append(command)
|
||
if link.get("handler") is not None:
|
||
bucket["command_handlers"][command["lineage_id"]] = _neo4j_node_to_named(link["handler"])
|
||
element_seen: set[tuple[str, str]] = set()
|
||
for link in element_links:
|
||
if not link or link.get("form") is None or link.get("element") is None:
|
||
continue
|
||
form = _neo4j_node_to_named(link["form"])
|
||
element = _neo4j_node_to_named(link["element"])
|
||
bucket = by_lineage.setdefault(
|
||
form["lineage_id"],
|
||
{"form": form, "commands": [], "elements": [], "command_handlers": {}},
|
||
)
|
||
element_key = (form["lineage_id"], element["lineage_id"])
|
||
if element_key not in element_seen:
|
||
element_seen.add(element_key)
|
||
bucket["elements"].append(element)
|
||
for bucket in by_lineage.values():
|
||
bucket["commands"] = sorted(bucket["commands"], key=lambda row: row["qualified_name"])
|
||
bucket["elements"] = sorted(bucket["elements"], key=lambda row: row["qualified_name"])
|
||
return sorted(by_lineage.values(), key=lambda row: row["form"]["qualified_name"])
|
||
|
||
|
||
def _neo4j_tabular_section_columns_to_named(links: list) -> dict[str, list[dict]]:
|
||
result: dict[str, dict[str, dict]] = {}
|
||
for link in links:
|
||
if not link or link.get("section") is None or link.get("column") is None:
|
||
continue
|
||
section = _neo4j_node_to_named(link["section"])
|
||
column = _neo4j_node_to_named(link["column"])
|
||
result.setdefault(section["lineage_id"], {})[column["lineage_id"]] = column
|
||
return {
|
||
section_lineage: sorted(columns.values(), key=lambda row: row["qualified_name"])
|
||
for section_lineage, columns in result.items()
|
||
}
|
||
|
||
|
||
def _neo4j_role_access_to_named(grants: list) -> list[dict]:
|
||
result: list[dict] = []
|
||
seen: set[str] = set()
|
||
for grant in grants:
|
||
if not grant or grant.get("role") is None:
|
||
continue
|
||
role = _neo4j_node_to_named(grant["role"])
|
||
if role["lineage_id"] in seen:
|
||
continue
|
||
seen.add(role["lineage_id"])
|
||
attributes_json = grant.get("attributes_json") or "{}"
|
||
result.append(
|
||
{
|
||
"role": role,
|
||
"permissions": json.loads(attributes_json),
|
||
}
|
||
)
|
||
return sorted(result, key=lambda row: row["role"]["qualified_name"])
|
||
|
||
|
||
def _neo4j_object_access_to_named(grants: list) -> list[dict]:
|
||
result: list[dict] = []
|
||
seen: set[str] = set()
|
||
for grant in grants:
|
||
if not grant or grant.get("object") is None:
|
||
continue
|
||
object_node = _neo4j_node_to_named(grant["object"])
|
||
if object_node["lineage_id"] in seen:
|
||
continue
|
||
seen.add(object_node["lineage_id"])
|
||
attributes_json = grant.get("attributes_json") or "{}"
|
||
result.append(
|
||
{
|
||
"object": object_node,
|
||
"permissions": json.loads(attributes_json),
|
||
}
|
||
)
|
||
return sorted(result, key=lambda row: row["object"]["qualified_name"])
|
||
|
||
|
||
def _neo4j_node_to_named(node) -> dict:
|
||
return {
|
||
"lineage_id": node["lineage_id"],
|
||
"kind": node["kind"],
|
||
"name": node["name"],
|
||
"qualified_name": node["qualified_name"],
|
||
}
|
||
|
||
|
||
def _form_semantics_response(item) -> FormSemanticsResponse:
|
||
return FormSemanticsResponse(
|
||
form=_named_node(item.form),
|
||
commands=[_named_node(node) for node in item.commands],
|
||
elements=[_named_node(node) for node in item.elements],
|
||
command_handlers={
|
||
command_lineage: _named_node(handler)
|
||
for command_lineage, handler in item.command_handlers.items()
|
||
},
|
||
)
|
||
|
||
|
||
def _form_semantics_for_lineages(snapshot: SirSnapshot, form_lineages: set[str]) -> list[FormSemanticsResponse]:
|
||
if not form_lineages:
|
||
return []
|
||
nodes = {node.lineage_id: node for node in snapshot.nodes}
|
||
forms = {
|
||
lineage_id: FormSemanticsResponse(form=_named_node(node), commands=[], elements=[], command_handlers={})
|
||
for lineage_id, node in nodes.items()
|
||
if lineage_id in form_lineages and node.kind == NodeKind.FORM
|
||
}
|
||
command_to_form: dict[str, FormSemanticsResponse] = {}
|
||
for edge in snapshot.edges:
|
||
form = forms.get(edge.source_lineage)
|
||
target = nodes.get(edge.target_lineage)
|
||
if form is None or target is None:
|
||
continue
|
||
if edge.kind == EdgeKind.HAS_COMMAND:
|
||
named_target = _named_node(target)
|
||
form.commands.append(named_target)
|
||
command_to_form[target.lineage_id] = form
|
||
elif edge.kind == EdgeKind.HAS_ELEMENT:
|
||
form.elements.append(_named_node(target))
|
||
for edge in snapshot.edges:
|
||
if edge.kind != EdgeKind.HANDLES:
|
||
continue
|
||
form = command_to_form.get(edge.source_lineage)
|
||
handler = nodes.get(edge.target_lineage)
|
||
if form is not None and handler is not None:
|
||
form.command_handlers[edge.source_lineage] = _named_node(handler)
|
||
return sorted(forms.values(), key=lambda item: item.form.qualified_name)
|
||
|
||
|
||
def _persist_job(job: OperationJob) -> OperationJob:
|
||
_storage.write_document("operations_jobs", job.job_id, job.model_dump(mode="json"))
|
||
return job
|
||
|
||
|
||
def _run_server_import_job(job_id: str, project_id: str, request: ImportRequest) -> None:
|
||
job = _operations.jobs.get(job_id)
|
||
if job is None:
|
||
return
|
||
_update_import_job_progress(job_id, "Импорт запущен.", stage="running", status=OperationJobStatus.RUNNING)
|
||
try:
|
||
summary = _execute_import_project(
|
||
project_id,
|
||
request,
|
||
progress=lambda message, extra=None: _update_import_job_progress(job_id, message, **(extra or {})),
|
||
)
|
||
except Exception as error:
|
||
_update_import_job_progress(
|
||
job_id,
|
||
f"Импорт завершился ошибкой: {error}",
|
||
stage="failed",
|
||
status=OperationJobStatus.FAILED,
|
||
error=str(error),
|
||
finished_at=_current_timestamp(),
|
||
)
|
||
return
|
||
status = OperationJobStatus.SUCCEEDED if summary.applied is not False and not summary.errors else OperationJobStatus.FAILED
|
||
_update_import_job_progress(
|
||
job_id,
|
||
"Импорт завершен." if status == OperationJobStatus.SUCCEEDED else "Импорт остановлен. Проверьте ошибки.",
|
||
stage="done" if status == OperationJobStatus.SUCCEEDED else "failed",
|
||
status=status,
|
||
result={"import_summary": summary.model_dump(mode="json")},
|
||
error="; ".join(summary.errors) if summary.errors else None,
|
||
finished_at=_current_timestamp(),
|
||
)
|
||
|
||
|
||
def _create_agent_server_import_job(agent_job: AgentImportJob, request: ImportRequest) -> OperationJob:
|
||
operation_job = OperationJob(
|
||
job_id=f"server-import-{uuid4()}",
|
||
kind="SERVER_IMPORT",
|
||
status=OperationJobStatus.QUEUED,
|
||
payload={
|
||
"project_id": agent_job.project_id,
|
||
"agent_job_id": agent_job.job_id,
|
||
"agent_id": agent_job.agent_id,
|
||
"source": agent_job.source.value,
|
||
"mode": request.mode.value,
|
||
"path": request.path,
|
||
"stage": "queued",
|
||
"message": "Импорт с Windows Agent поставлен в очередь.",
|
||
"logs": ["Импорт с Windows Agent поставлен в очередь."],
|
||
"started_at": None,
|
||
"finished_at": None,
|
||
"bytes_copied": 0,
|
||
"files_copied": 0,
|
||
},
|
||
)
|
||
_persist_job(_operations.enqueue(operation_job))
|
||
return _operations.jobs[operation_job.job_id]
|
||
|
||
|
||
def _append_agent_job_log(agent_job_id: str, message: str) -> AgentImportJob | None:
|
||
agent_job = _agent_import_jobs.get(agent_job_id)
|
||
if agent_job is None:
|
||
return None
|
||
if message and (not agent_job.logs or agent_job.logs[-1] != message):
|
||
agent_job.logs.append(message)
|
||
agent_job.updated_at = _current_timestamp()
|
||
return _persist_agent_import_job(agent_job)
|
||
|
||
|
||
def _run_agent_server_import_job(agent_job_id: str, operation_job_id: str, project_id: str, request: ImportRequest) -> None:
|
||
agent_job = _agent_import_jobs.get(agent_job_id)
|
||
if agent_job is None:
|
||
return
|
||
agent_job.status = AgentImportJobStatus.RUNNING
|
||
agent_job.error = None
|
||
agent_job.updated_at = _current_timestamp()
|
||
_persist_agent_import_job(agent_job)
|
||
_update_import_job_progress(
|
||
operation_job_id,
|
||
"Импорт с Windows Agent запущен.",
|
||
stage="running",
|
||
status=OperationJobStatus.RUNNING,
|
||
)
|
||
|
||
def progress(message: str, extra: dict[str, Any] | None = None) -> None:
|
||
_update_import_job_progress(operation_job_id, message, **(extra or {}))
|
||
if message:
|
||
_append_agent_job_log(agent_job_id, f"Server import: {message}")
|
||
|
||
try:
|
||
summary = _execute_import_project(project_id, request, progress=progress)
|
||
except Exception as error:
|
||
message = f"Server import failed: {error}"
|
||
agent_job = _agent_import_jobs.get(agent_job_id)
|
||
if agent_job is not None:
|
||
agent_job.status = AgentImportJobStatus.FAILED
|
||
agent_job.error = str(error)
|
||
agent_job.logs.append(message)
|
||
agent_job.updated_at = _current_timestamp()
|
||
agent_job.completed_at = agent_job.updated_at
|
||
_persist_agent_import_job(agent_job)
|
||
_update_import_job_progress(
|
||
operation_job_id,
|
||
f"Импорт завершился ошибкой: {error}",
|
||
stage="failed",
|
||
status=OperationJobStatus.FAILED,
|
||
error=str(error),
|
||
finished_at=_current_timestamp(),
|
||
)
|
||
return
|
||
|
||
status = OperationJobStatus.SUCCEEDED if summary.applied is not False and not summary.errors else OperationJobStatus.FAILED
|
||
agent_job = _agent_import_jobs.get(agent_job_id)
|
||
if agent_job is not None:
|
||
agent_job.status = AgentImportJobStatus.SUCCEEDED if status == OperationJobStatus.SUCCEEDED else AgentImportJobStatus.FAILED
|
||
agent_job.import_summary = summary.model_dump(mode="json")
|
||
agent_job.error = "; ".join(summary.errors) if summary.errors else None
|
||
agent_job.logs.append(f"Server import applied with status {summary.status}.")
|
||
agent_job.updated_at = _current_timestamp()
|
||
agent_job.completed_at = agent_job.updated_at
|
||
_persist_agent_import_job(agent_job)
|
||
_update_import_job_progress(
|
||
operation_job_id,
|
||
"Импорт завершен." if status == OperationJobStatus.SUCCEEDED else "Импорт остановлен. Проверьте ошибки.",
|
||
stage="done" if status == OperationJobStatus.SUCCEEDED else "failed",
|
||
status=status,
|
||
result={"import_summary": summary.model_dump(mode="json")},
|
||
error="; ".join(summary.errors) if summary.errors else None,
|
||
finished_at=_current_timestamp(),
|
||
)
|
||
|
||
|
||
def _run_agent_uploaded_zip_job(job_id: str, upload_path_raw: str, import_root_raw: str, filename: str, size: int) -> None:
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is None:
|
||
return
|
||
upload_path = Path(upload_path_raw)
|
||
import_root = Path(import_root_raw)
|
||
try:
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.error = None
|
||
job.logs.append("Server extraction started.")
|
||
job.updated_at = _current_timestamp()
|
||
_persist_agent_import_job(job)
|
||
_touch_agent_activity(job.agent_id)
|
||
|
||
if import_root.exists():
|
||
shutil.rmtree(import_root)
|
||
import_root.mkdir(parents=True, exist_ok=True)
|
||
last_touch = time.monotonic()
|
||
|
||
def extraction_progress() -> None:
|
||
nonlocal last_touch
|
||
if time.monotonic() - last_touch >= 30:
|
||
_touch_agent_activity(job.agent_id)
|
||
last_touch = time.monotonic()
|
||
|
||
with zipfile.ZipFile(upload_path) as archive:
|
||
_extract_zip_safely(archive, import_root, progress_callback=extraction_progress)
|
||
try:
|
||
upload_path.unlink()
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is None:
|
||
return
|
||
job.server_path = import_root.as_posix()
|
||
job.metadata = {**job.metadata, "upload_processing": False, "upload_path": None}
|
||
job.logs.append(f"Uploaded and extracted {filename} ({size} bytes) to {job.server_path}.")
|
||
job.updated_at = _current_timestamp()
|
||
_persist_agent_import_job(job)
|
||
_touch_agent_activity(job.agent_id)
|
||
|
||
import_request = ImportRequest(
|
||
source=job.source,
|
||
path=job.server_path,
|
||
credentials_ref=job.credentials_ref,
|
||
metadata={
|
||
**job.metadata,
|
||
"agent_id": job.agent_id,
|
||
"agent_local_path": job.local_path,
|
||
"agent_bin_path": job.bin_path,
|
||
"agent_infobase": job.infobase,
|
||
},
|
||
structure_only=job.source in {ImportSourceKind.EDT_PROJECT, ImportSourceKind.XML_DUMP},
|
||
mode=job.mode,
|
||
)
|
||
operation_job = _create_agent_server_import_job(job, import_request)
|
||
job.status = AgentImportJobStatus.RUNNING
|
||
job.completed_at = None
|
||
job.metadata = {**job.metadata, "server_import_job_id": operation_job.job_id}
|
||
job.logs.append(f"Server import queued as {operation_job.job_id}.")
|
||
job.updated_at = _current_timestamp()
|
||
_persist_agent_import_job(job)
|
||
_run_agent_server_import_job(job.job_id, operation_job.job_id, job.project_id, import_request)
|
||
except Exception as error:
|
||
job = _agent_import_jobs.get(job_id)
|
||
if job is not None:
|
||
job.status = AgentImportJobStatus.FAILED
|
||
job.error = str(error)
|
||
job.logs.append(f"Server extraction failed: {error}")
|
||
job.updated_at = _current_timestamp()
|
||
job.completed_at = job.updated_at
|
||
job.metadata = {**job.metadata, "upload_processing": False}
|
||
_persist_agent_import_job(job)
|
||
|
||
|
||
def _active_server_import_job(project_id: str) -> OperationJob | None:
|
||
active_statuses = {OperationJobStatus.QUEUED, OperationJobStatus.RUNNING}
|
||
candidates = [
|
||
job
|
||
for job in _operations.jobs.values()
|
||
if job.kind == "SERVER_IMPORT"
|
||
and job.payload.get("project_id") == project_id
|
||
and job.status in active_statuses
|
||
]
|
||
if not candidates:
|
||
return None
|
||
return sorted(candidates, key=lambda item: item.created_at, reverse=True)[0]
|
||
|
||
|
||
def _update_import_job_progress(
|
||
job_id: str,
|
||
message: str,
|
||
*,
|
||
status: OperationJobStatus | None = None,
|
||
result: dict | None = None,
|
||
error: str | None = None,
|
||
**extra,
|
||
) -> OperationJob | None:
|
||
job = _operations.jobs.get(job_id)
|
||
if job is None:
|
||
return None
|
||
payload = dict(job.payload)
|
||
logs = list(payload.get("logs") if isinstance(payload.get("logs"), list) else [])
|
||
if message and (not logs or logs[-1] != message):
|
||
logs.append(message)
|
||
payload.update(extra)
|
||
payload["message"] = message
|
||
payload["logs"] = logs[-50:]
|
||
if status == OperationJobStatus.RUNNING and not payload.get("started_at"):
|
||
payload["started_at"] = _current_timestamp()
|
||
job.payload = payload
|
||
if status is not None:
|
||
job.status = status
|
||
if result is not None:
|
||
job.result = result
|
||
job.error = error
|
||
from datetime import datetime, timezone
|
||
|
||
job.updated_at = datetime.now(timezone.utc)
|
||
_persist_job(job)
|
||
return job
|
||
|
||
|
||
def _emit_import_progress(progress: ImportProgressCallback | None, message: str, **extra) -> None:
|
||
if progress is not None:
|
||
progress(message, extra)
|
||
|
||
|
||
def _persist_agent_import_job(job: AgentImportJob) -> AgentImportJob:
|
||
_agent_import_jobs[job.job_id] = job
|
||
_storage.write_document("agent_import_jobs", job.job_id, job.model_dump(mode="json"))
|
||
return job
|
||
|
||
|
||
def _persist_agent_browse_request(request: AgentBrowseRequest) -> AgentBrowseRequest:
|
||
_agent_browse_requests[request.request_id] = request
|
||
_storage.write_document("agent_browse_requests", request.request_id, request.model_dump(mode="json"))
|
||
return request
|
||
|
||
|
||
def _persist_agent_status(status: AgentStatus) -> AgentStatus:
|
||
status = _agent_status_with_liveness(status)
|
||
_agent_statuses[status.agent_id] = status
|
||
_storage.write_document("agent_statuses", status.agent_id, status.model_dump(mode="json"))
|
||
return status
|
||
|
||
|
||
def _touch_agent_activity(agent_id: str) -> None:
|
||
if not agent_id:
|
||
return
|
||
status = _agent_statuses.get(agent_id) or AgentStatus(agent_id=agent_id)
|
||
status.last_seen_at = _current_timestamp()
|
||
status.status = "online"
|
||
_persist_agent_status(status)
|
||
|
||
|
||
def _browse_smb_folders(
|
||
*,
|
||
path: str,
|
||
username: str | None,
|
||
password: str | None,
|
||
domain: str | None,
|
||
) -> ServerBrowseResponse:
|
||
try:
|
||
import smbclient
|
||
except ImportError as error:
|
||
raise RuntimeError("SMB client dependency is not installed on the API server.") from error
|
||
|
||
server, _share, _relative = _parse_unc_path(path)
|
||
if username:
|
||
qualified_user = f"{domain}\\{username}" if domain else username
|
||
smbclient.register_session(server, username=qualified_user, password=password or "")
|
||
else:
|
||
smbclient.register_session(server)
|
||
|
||
entries: list[AgentFolderEntry] = []
|
||
base_path = path.rstrip("\\")
|
||
for item in smbclient.scandir(path):
|
||
try:
|
||
if not item.is_dir():
|
||
continue
|
||
except OSError:
|
||
continue
|
||
child_path = f"{base_path}\\{item.name}"
|
||
entries.append(AgentFolderEntry(name=item.name, path=child_path, is_directory=True))
|
||
if len(entries) >= 200:
|
||
break
|
||
parent = _unc_parent_path(path)
|
||
return ServerBrowseResponse(path=path, parent_path=parent, entries=sorted(entries, key=lambda item: item.name.lower()))
|
||
|
||
|
||
def _copy_smb_tree_to_local(
|
||
*,
|
||
source: str,
|
||
target: Path,
|
||
username: str | None,
|
||
password: str | None,
|
||
domain: str | None,
|
||
progress: ImportProgressCallback | None = None,
|
||
) -> None:
|
||
try:
|
||
import smbclient
|
||
except ImportError as error:
|
||
raise RuntimeError("SMB client dependency is not installed on the API server.") from error
|
||
|
||
server, _share, _relative = _parse_unc_path(source)
|
||
if username:
|
||
qualified_user = f"{domain}\\{username}" if domain else username
|
||
smbclient.register_session(server, username=qualified_user, password=password or "")
|
||
else:
|
||
smbclient.register_session(server)
|
||
stats = {"bytes_copied": 0, "files_copied": 0, "last_emit": 0.0}
|
||
_copy_smb_directory(smbclient, source.rstrip("\\"), target, progress=progress, stats=stats)
|
||
|
||
|
||
def _copy_smb_directory(
|
||
smbclient_module,
|
||
source: str,
|
||
target: Path,
|
||
*,
|
||
progress: ImportProgressCallback | None,
|
||
stats: dict[str, float | int],
|
||
) -> None:
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
for item in smbclient_module.scandir(source):
|
||
destination = target / item.name
|
||
child_source = f"{source}\\{item.name}"
|
||
try:
|
||
if item.is_dir():
|
||
_emit_import_progress(
|
||
progress,
|
||
f"Чтение папки: {child_source}",
|
||
stage="copying",
|
||
bytes_copied=int(stats["bytes_copied"]),
|
||
files_copied=int(stats["files_copied"]),
|
||
)
|
||
_copy_smb_directory(smbclient_module, child_source, destination, progress=progress, stats=stats)
|
||
continue
|
||
except OSError:
|
||
continue
|
||
with smbclient_module.open_file(child_source, mode="rb") as remote_file:
|
||
with destination.open("wb") as local_file:
|
||
while True:
|
||
chunk = remote_file.read(1024 * 1024)
|
||
if not chunk:
|
||
break
|
||
local_file.write(chunk)
|
||
stats["bytes_copied"] = int(stats["bytes_copied"]) + len(chunk)
|
||
now = time.monotonic()
|
||
if now - float(stats["last_emit"]) >= 2:
|
||
stats["last_emit"] = now
|
||
_emit_import_progress(
|
||
progress,
|
||
f"Копирование SMB: {_format_bytes(int(stats['bytes_copied']))}, файлов {int(stats['files_copied'])}",
|
||
stage="copying",
|
||
bytes_copied=int(stats["bytes_copied"]),
|
||
files_copied=int(stats["files_copied"]),
|
||
)
|
||
stats["files_copied"] = int(stats["files_copied"]) + 1
|
||
_emit_import_progress(
|
||
progress,
|
||
f"Скопирован файл: {item.name}",
|
||
stage="copying",
|
||
bytes_copied=int(stats["bytes_copied"]),
|
||
files_copied=int(stats["files_copied"]),
|
||
)
|
||
|
||
|
||
def _format_bytes(value: int) -> str:
|
||
units = ["B", "KB", "MB", "GB", "TB"]
|
||
amount = float(value)
|
||
for unit in units:
|
||
if amount < 1024 or unit == units[-1]:
|
||
return f"{amount:.1f} {unit}" if unit != "B" else f"{int(amount)} B"
|
||
amount /= 1024
|
||
return f"{value} B"
|
||
|
||
|
||
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 _unc_parent_path(path: str) -> str | None:
|
||
parts = [part for part in path.strip("\\").split("\\") if part]
|
||
if len(parts) <= 2:
|
||
return None
|
||
return "\\\\" + "\\".join(parts[:-1])
|
||
|
||
|
||
def _safe_storage_name(value: str) -> str:
|
||
return "".join(character if character.isalnum() or character in {"-", "_", "."} else "_" for character in value)
|
||
|
||
|
||
def _extract_zip_safely(
|
||
archive: zipfile.ZipFile,
|
||
destination: Path,
|
||
progress_callback: Callable[[], None] | None = None,
|
||
) -> None:
|
||
destination_root = destination.resolve()
|
||
for member in archive.infolist():
|
||
target = (destination_root / member.filename).resolve()
|
||
if destination_root != target and destination_root not in target.parents:
|
||
raise HTTPException(status_code=400, detail=f"Unsafe zip member path: {member.filename}")
|
||
archive.extract(member, destination_root)
|
||
if progress_callback is not None:
|
||
progress_callback()
|
||
|
||
|
||
def _append_and_persist_versions(snapshot: SirSnapshot) -> list[SemanticObjectVersion]:
|
||
versions = _versions.append_snapshot_versions(
|
||
snapshot,
|
||
task_id=snapshot.metadata.task_id,
|
||
session_id=snapshot.metadata.session_id,
|
||
)
|
||
for version in versions:
|
||
_storage.write_document("object_versions", version.version_id, version.model_dump(mode="json"))
|
||
return versions
|
||
|
||
|
||
def _version_summary(version: SemanticObjectVersion) -> ObjectVersionSummary:
|
||
return ObjectVersionSummary(
|
||
version_id=version.version_id,
|
||
lineage_id=version.lineage_id,
|
||
semantic_id=version.semantic_id,
|
||
object_hash=version.object_hash,
|
||
parent_version_id=version.parent_version_id,
|
||
task_id=version.task_id,
|
||
session_id=version.session_id,
|
||
)
|
||
|
||
|
||
def _find_snapshot_node(snapshot: SirSnapshot, lineage_id: str):
|
||
return next((node for node in snapshot.nodes if node.lineage_id == lineage_id), None)
|
||
|
||
|
||
def _source_location(source_ref) -> SourceLocationResponse:
|
||
if source_ref is None:
|
||
return SourceLocationResponse()
|
||
return SourceLocationResponse(
|
||
source_path=source_ref.source_path,
|
||
line_start=source_ref.line_start,
|
||
line_end=source_ref.line_end,
|
||
column_start=source_ref.column_start,
|
||
column_end=source_ref.column_end,
|
||
)
|
||
|
||
|
||
def _symbol_response(node) -> SymbolResponse:
|
||
return SymbolResponse(node=_named_node(node), source=_source_location(node.source_ref))
|
||
|
||
|
||
def _named_node(node) -> NamedNode:
|
||
return NamedNode(
|
||
lineage_id=node.lineage_id,
|
||
kind=node.kind.value,
|
||
name=node.name,
|
||
qualified_name=node.qualified_name,
|
||
)
|