From 3dcf80eaaa8eef31fc5c903c4b0d4b0433d0c13b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Tue, 31 Mar 2026 18:10:13 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20consolidar=20governa?= =?UTF-8?q?nca=20segura=20de=20tools=20na=20fase=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_app/api/dependencies.py | 14 + admin_app/api/schemas.py | 9 + admin_app/db/bootstrap.py | 4 +- admin_app/db/models/__init__.py | 4 + admin_app/db/models/tool_artifact.py | 113 ++++++++ admin_app/db/models/tool_metadata.py | 54 ++++ admin_app/db/write_governance.py | 2 + admin_app/repositories/__init__.py | 4 + .../repositories/tool_artifact_repository.py | 213 +++++++++++++++ .../repositories/tool_metadata_repository.py | 144 ++++++++++ admin_app/services/tool_management_service.py | 224 ++++++++++++++- admin_app/view/static/scripts/panel.js | 2 +- app/db/bootstrap.py | 25 ++ app/services/tools/tool_registry.py | 54 ++++ generated_tools/README.md | 3 + generated_tools/__init__.py | 1 + shared/contracts/__init__.py | 8 + shared/contracts/tool_publication.py | 23 ++ tests/test_admin_panel_tools_web.py | 253 ++++++++++++++++- tests/test_admin_tool_artifact_model.py | 61 +++++ tests/test_admin_tool_management_service.py | 246 ++++++++++++++++- tests/test_admin_tool_metadata_model.py | 49 ++++ tests/test_admin_tools_web.py | 255 +++++++++++++++++- tests/test_admin_write_governance.py | 19 +- tests/test_conversation_adjustments.py | 56 +++- tests/test_runtime_bootstrap.py | 33 +++ tests/test_shared_contracts.py | 16 ++ 27 files changed, 1849 insertions(+), 40 deletions(-) create mode 100644 admin_app/db/models/tool_artifact.py create mode 100644 admin_app/db/models/tool_metadata.py create mode 100644 admin_app/repositories/tool_artifact_repository.py create mode 100644 admin_app/repositories/tool_metadata_repository.py create mode 100644 generated_tools/README.md create mode 100644 generated_tools/__init__.py create mode 100644 tests/test_admin_tool_artifact_model.py create mode 100644 tests/test_admin_tool_metadata_model.py diff --git a/admin_app/api/dependencies.py b/admin_app/api/dependencies.py index 6ff05a7..0571b78 100644 --- a/admin_app/api/dependencies.py +++ b/admin_app/api/dependencies.py @@ -15,7 +15,9 @@ from admin_app.repositories import ( AuditLogRepository, StaffAccountRepository, StaffSessionRepository, + ToolArtifactRepository, ToolDraftRepository, + ToolMetadataRepository, ToolVersionRepository, ) from admin_app.services import ( @@ -64,6 +66,14 @@ def get_tool_version_repository(db: Session = Depends(get_admin_db)) -> ToolVers return ToolVersionRepository(db) +def get_tool_metadata_repository(db: Session = Depends(get_admin_db)) -> ToolMetadataRepository: + return ToolMetadataRepository(db) + + +def get_tool_artifact_repository(db: Session = Depends(get_admin_db)) -> ToolArtifactRepository: + return ToolArtifactRepository(db) + + def get_audit_service( repository: AuditLogRepository = Depends(get_audit_log_repository), ) -> AuditService: @@ -100,11 +110,15 @@ def get_tool_management_service( settings: AdminSettings = Depends(get_settings), draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository), version_repository: ToolVersionRepository = Depends(get_tool_version_repository), + metadata_repository: ToolMetadataRepository = Depends(get_tool_metadata_repository), + artifact_repository: ToolArtifactRepository = Depends(get_tool_artifact_repository), ) -> ToolManagementService: return ToolManagementService( settings=settings, draft_repository=draft_repository, version_repository=version_repository, + metadata_repository=metadata_repository, + artifact_repository=artifact_repository, ) diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 0c5a554..0bbdf3c 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -772,6 +772,13 @@ class AdminToolReviewQueueResponse(BaseModel): supported_statuses: list[ToolLifecycleStatus] +class AdminToolPublicationParameterResponse(BaseModel): + name: str + parameter_type: ToolParameterType + description: str + required: bool + + class AdminToolPublicationSummaryResponse(BaseModel): publication_id: str tool_name: str @@ -781,6 +788,8 @@ class AdminToolPublicationSummaryResponse(BaseModel): version: int status: ToolLifecycleStatus parameter_count: int + parameters: list[AdminToolPublicationParameterResponse] = Field(default_factory=list) + author_name: str | None = None implementation_module: str implementation_callable: str published_by: str | None = None diff --git a/admin_app/db/bootstrap.py b/admin_app/db/bootstrap.py index 3077fb3..1ed2f63 100644 --- a/admin_app/db/bootstrap.py +++ b/admin_app/db/bootstrap.py @@ -6,9 +6,9 @@ Cria tabelas do dominio administrativo de forma explicita, fora do startup do ap from sqlalchemy import inspect, text from admin_app.db.database import AdminBase, admin_engine -from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion +from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion -_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion) +_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion) def _ensure_admin_schema_evolution() -> None: diff --git a/admin_app/db/models/__init__.py b/admin_app/db/models/__init__.py index 261e021..ce2357c 100644 --- a/admin_app/db/models/__init__.py +++ b/admin_app/db/models/__init__.py @@ -2,7 +2,9 @@ from admin_app.db.models.audit_log import AuditLog from admin_app.db.models.base import AdminTimestampedModel from admin_app.db.models.staff_account import StaffAccount from admin_app.db.models.staff_session import StaffSession +from admin_app.db.models.tool_artifact import ToolArtifact from admin_app.db.models.tool_draft import ToolDraft +from admin_app.db.models.tool_metadata import ToolMetadata from admin_app.db.models.tool_version import ToolVersion __all__ = [ @@ -10,6 +12,8 @@ __all__ = [ "AuditLog", "StaffAccount", "StaffSession", + "ToolArtifact", "ToolDraft", + "ToolMetadata", "ToolVersion", ] diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py new file mode 100644 index 0000000..79be380 --- /dev/null +++ b/admin_app/db/models/tool_artifact.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from enum import Enum + +from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import TypeDecorator + +from admin_app.db.models.base import AdminTimestampedModel + + +class ToolArtifactStage(str, Enum): + GENERATION = "generation" + VALIDATION = "validation" + + +class ToolArtifactKind(str, Enum): + GENERATION_REQUEST = "generation_request" + VALIDATION_REPORT = "validation_report" + + +class ToolArtifactStorageKind(str, Enum): + INLINE_JSON = "inline_json" + + +class ToolArtifactStatus(str, Enum): + PENDING = "pending" + SUCCEEDED = "succeeded" + FAILED = "failed" + + +class ToolArtifactEnumType(TypeDecorator): + impl = String(40) + cache_ok = True + + def __init__(self, enum_cls: type[Enum], *, length: int = 40): + super().__init__(length=length) + self.enum_cls = enum_cls + + @property + def python_type(self): + return self.enum_cls + + def process_bind_param(self, value, dialect): + if value is None: + return None + if isinstance(value, self.enum_cls): + return value.value + return self.enum_cls(str(value).strip().lower()).value + + def process_result_value(self, value, dialect): + if value is None: + return None + return self.enum_cls(str(value).strip().lower()) + + +class ToolArtifact(AdminTimestampedModel): + __tablename__ = "tool_artifacts" + __table_args__ = ( + UniqueConstraint( + "tool_version_id", + "artifact_kind", + name="uq_tool_artifacts_tool_version_kind", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + artifact_id: Mapped[str] = mapped_column(String(140), unique=True, index=True, nullable=False) + draft_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("tool_drafts.id"), + nullable=False, + index=True, + ) + tool_version_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("tool_versions.id"), + nullable=False, + index=True, + ) + tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + artifact_stage: Mapped[ToolArtifactStage] = mapped_column( + ToolArtifactEnumType(ToolArtifactStage), + nullable=False, + index=True, + ) + artifact_kind: Mapped[ToolArtifactKind] = mapped_column( + ToolArtifactEnumType(ToolArtifactKind), + nullable=False, + index=True, + ) + artifact_status: Mapped[ToolArtifactStatus] = mapped_column( + ToolArtifactEnumType(ToolArtifactStatus), + nullable=False, + default=ToolArtifactStatus.PENDING, + index=True, + ) + storage_kind: Mapped[ToolArtifactStorageKind] = mapped_column( + ToolArtifactEnumType(ToolArtifactStorageKind), + nullable=False, + default=ToolArtifactStorageKind.INLINE_JSON, + ) + summary: Mapped[str] = mapped_column(Text, nullable=False) + payload_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + checksum: Mapped[str | None] = mapped_column(String(64), nullable=True) + author_staff_account_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("staff_accounts.id"), + nullable=False, + index=True, + ) + author_display_name: Mapped[str] = mapped_column(String(150), nullable=False) diff --git a/admin_app/db/models/tool_metadata.py b/admin_app/db/models/tool_metadata.py new file mode 100644 index 0000000..e44bfc5 --- /dev/null +++ b/admin_app/db/models/tool_metadata.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from admin_app.db.models.base import AdminTimestampedModel +from admin_app.db.models.tool_draft import ToolLifecycleStatusType +from shared.contracts import ToolLifecycleStatus + + +class ToolMetadata(AdminTimestampedModel): + __tablename__ = "tool_metadata" + __table_args__ = ( + UniqueConstraint( + "tool_name", + "version_number", + name="uq_tool_metadata_tool_name_version_number", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + metadata_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False) + draft_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("tool_drafts.id"), + nullable=False, + index=True, + ) + tool_version_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("tool_versions.id"), + nullable=False, + unique=True, + index=True, + ) + tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False) + display_name: Mapped[str] = mapped_column(String(120), nullable=False) + domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[ToolLifecycleStatus] = mapped_column( + ToolLifecycleStatusType(), + nullable=False, + default=ToolLifecycleStatus.DRAFT, + index=True, + ) + author_staff_account_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("staff_accounts.id"), + nullable=False, + index=True, + ) + author_display_name: Mapped[str] = mapped_column(String(150), nullable=False) diff --git a/admin_app/db/write_governance.py b/admin_app/db/write_governance.py index 71f452f..d41353b 100644 --- a/admin_app/db/write_governance.py +++ b/admin_app/db/write_governance.py @@ -14,6 +14,8 @@ ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = ( "staff_sessions", "tool_drafts", "tool_versions", + "tool_metadata", + "tool_artifacts", ) diff --git a/admin_app/repositories/__init__.py b/admin_app/repositories/__init__.py index 391c4ca..2a6f6ca 100644 --- a/admin_app/repositories/__init__.py +++ b/admin_app/repositories/__init__.py @@ -2,7 +2,9 @@ from admin_app.repositories.audit_log_repository import AuditLogRepository from admin_app.repositories.base_repository import BaseRepository from admin_app.repositories.staff_account_repository import StaffAccountRepository from admin_app.repositories.staff_session_repository import StaffSessionRepository +from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository from admin_app.repositories.tool_draft_repository import ToolDraftRepository +from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository __all__ = [ @@ -10,6 +12,8 @@ __all__ = [ "BaseRepository", "StaffAccountRepository", "StaffSessionRepository", + "ToolArtifactRepository", "ToolDraftRepository", + "ToolMetadataRepository", "ToolVersionRepository", ] diff --git a/admin_app/repositories/tool_artifact_repository.py b/admin_app/repositories/tool_artifact_repository.py new file mode 100644 index 0000000..e6ab57e --- /dev/null +++ b/admin_app/repositories/tool_artifact_repository.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import hashlib +import json + +from sqlalchemy import select + +from admin_app.db.models import ToolArtifact +from admin_app.db.models.tool_artifact import ( + ToolArtifactKind, + ToolArtifactStage, + ToolArtifactStatus, + ToolArtifactStorageKind, +) +from admin_app.repositories.base_repository import BaseRepository + + +class ToolArtifactRepository(BaseRepository): + def list_artifacts( + self, + *, + tool_name: str | None = None, + tool_version_id: int | None = None, + artifact_stage: ToolArtifactStage | str | None = None, + artifact_kind: ToolArtifactKind | str | None = None, + ) -> list[ToolArtifact]: + statement = select(ToolArtifact).order_by( + ToolArtifact.version_number.desc(), + ToolArtifact.updated_at.desc(), + ToolArtifact.created_at.desc(), + ) + if tool_name: + statement = statement.where(ToolArtifact.tool_name == str(tool_name).strip().lower()) + if tool_version_id is not None: + statement = statement.where(ToolArtifact.tool_version_id == tool_version_id) + if artifact_stage: + statement = statement.where( + ToolArtifact.artifact_stage == self._normalize_stage(artifact_stage) + ) + if artifact_kind: + statement = statement.where( + ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind) + ) + return list(self.db.execute(statement).scalars().all()) + + def get_by_tool_version_and_kind( + self, + tool_version_id: int, + artifact_kind: ToolArtifactKind | str, + ) -> ToolArtifact | None: + statement = select(ToolArtifact).where( + ToolArtifact.tool_version_id == tool_version_id, + ToolArtifact.artifact_kind == self._normalize_kind(artifact_kind), + ) + return self.db.execute(statement).scalar_one_or_none() + + def create( + self, + *, + draft_id: int, + tool_version_id: int, + tool_name: str, + version_number: int, + artifact_stage: ToolArtifactStage | str, + artifact_kind: ToolArtifactKind | str, + artifact_status: ToolArtifactStatus | str, + summary: str, + payload_json: dict, + author_staff_account_id: int, + author_display_name: str, + storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON, + checksum: str | None = None, + commit: bool = True, + ) -> ToolArtifact: + normalized_kind = self._normalize_kind(artifact_kind) + artifact = ToolArtifact( + artifact_id=self.build_artifact_id(tool_name, version_number, normalized_kind), + draft_id=draft_id, + tool_version_id=tool_version_id, + tool_name=str(tool_name or "").strip().lower(), + version_number=int(version_number), + artifact_stage=self._normalize_stage(artifact_stage), + artifact_kind=normalized_kind, + artifact_status=self._normalize_status(artifact_status), + storage_kind=self._normalize_storage_kind(storage_kind), + summary=str(summary or "").strip(), + payload_json=dict(payload_json or {}), + checksum=checksum or self._build_payload_checksum(payload_json), + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + ) + self.db.add(artifact) + if commit: + self.db.commit() + self.db.refresh(artifact) + else: + self.db.flush() + return artifact + + def update_artifact( + self, + artifact: ToolArtifact, + *, + artifact_status: ToolArtifactStatus | str, + summary: str, + payload_json: dict, + author_staff_account_id: int, + author_display_name: str, + storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON, + checksum: str | None = None, + commit: bool = True, + ) -> ToolArtifact: + artifact.artifact_status = self._normalize_status(artifact_status) + artifact.storage_kind = self._normalize_storage_kind(storage_kind) + artifact.summary = str(summary or "").strip() + artifact.payload_json = dict(payload_json or {}) + artifact.checksum = checksum or self._build_payload_checksum(payload_json) + artifact.author_staff_account_id = author_staff_account_id + artifact.author_display_name = author_display_name + if commit: + self.db.commit() + self.db.refresh(artifact) + else: + self.db.flush() + return artifact + + def upsert_version_artifact( + self, + *, + draft_id: int, + tool_version_id: int, + tool_name: str, + version_number: int, + artifact_stage: ToolArtifactStage | str, + artifact_kind: ToolArtifactKind | str, + artifact_status: ToolArtifactStatus | str, + summary: str, + payload_json: dict, + author_staff_account_id: int, + author_display_name: str, + storage_kind: ToolArtifactStorageKind | str = ToolArtifactStorageKind.INLINE_JSON, + checksum: str | None = None, + commit: bool = True, + ) -> ToolArtifact: + normalized_kind = self._normalize_kind(artifact_kind) + existing = self.get_by_tool_version_and_kind(tool_version_id, normalized_kind) + if existing is None: + return self.create( + draft_id=draft_id, + tool_version_id=tool_version_id, + tool_name=tool_name, + version_number=version_number, + artifact_stage=artifact_stage, + artifact_kind=normalized_kind, + artifact_status=artifact_status, + summary=summary, + payload_json=payload_json, + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + storage_kind=storage_kind, + checksum=checksum, + commit=commit, + ) + return self.update_artifact( + existing, + artifact_status=artifact_status, + summary=summary, + payload_json=payload_json, + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + storage_kind=storage_kind, + checksum=checksum, + commit=commit, + ) + + @staticmethod + def build_artifact_id( + tool_name: str, + version_number: int, + artifact_kind: ToolArtifactKind | str, + ) -> str: + normalized_tool_name = str(tool_name or "").strip().lower() + normalized_kind = ToolArtifactRepository._normalize_kind(artifact_kind) + return f"tool_artifact::{normalized_tool_name}::v{int(version_number)}::{normalized_kind.value}" + + @staticmethod + def _build_payload_checksum(payload_json: dict | None) -> str: + canonical_payload = json.dumps(payload_json or {}, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical_payload.encode("utf-8")).hexdigest() + + @staticmethod + def _normalize_stage(value: ToolArtifactStage | str) -> ToolArtifactStage: + if isinstance(value, ToolArtifactStage): + return value + return ToolArtifactStage(str(value or "").strip().lower()) + + @staticmethod + def _normalize_kind(value: ToolArtifactKind | str) -> ToolArtifactKind: + if isinstance(value, ToolArtifactKind): + return value + return ToolArtifactKind(str(value or "").strip().lower()) + + @staticmethod + def _normalize_status(value: ToolArtifactStatus | str) -> ToolArtifactStatus: + if isinstance(value, ToolArtifactStatus): + return value + return ToolArtifactStatus(str(value or "").strip().lower()) + + @staticmethod + def _normalize_storage_kind(value: ToolArtifactStorageKind | str) -> ToolArtifactStorageKind: + if isinstance(value, ToolArtifactStorageKind): + return value + return ToolArtifactStorageKind(str(value or "").strip().lower()) diff --git a/admin_app/repositories/tool_metadata_repository.py b/admin_app/repositories/tool_metadata_repository.py new file mode 100644 index 0000000..15b59d0 --- /dev/null +++ b/admin_app/repositories/tool_metadata_repository.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from sqlalchemy import select + +from admin_app.db.models import ToolMetadata +from admin_app.repositories.base_repository import BaseRepository +from shared.contracts import ToolLifecycleStatus + + +class ToolMetadataRepository(BaseRepository): + def list_metadata( + self, + *, + tool_name: str | None = None, + statuses: tuple[ToolLifecycleStatus, ...] | None = None, + ) -> list[ToolMetadata]: + statement = select(ToolMetadata).order_by( + ToolMetadata.version_number.desc(), + ToolMetadata.updated_at.desc(), + ToolMetadata.created_at.desc(), + ) + if tool_name: + statement = statement.where(ToolMetadata.tool_name == str(tool_name).strip().lower()) + if statuses: + statement = statement.where(ToolMetadata.status.in_(statuses)) + return list(self.db.execute(statement).scalars().all()) + + def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None: + statement = select(ToolMetadata).where(ToolMetadata.tool_version_id == tool_version_id) + return self.db.execute(statement).scalar_one_or_none() + + def create( + self, + *, + draft_id: int, + tool_version_id: int, + tool_name: str, + display_name: str, + domain: str, + description: str, + parameters_json: list[dict], + version_number: int, + status: ToolLifecycleStatus, + author_staff_account_id: int, + author_display_name: str, + commit: bool = True, + ) -> ToolMetadata: + metadata = ToolMetadata( + metadata_id=self.build_metadata_id(tool_name, version_number), + draft_id=draft_id, + tool_version_id=tool_version_id, + tool_name=tool_name, + display_name=display_name, + domain=domain, + description=description, + parameters_json=parameters_json, + version_number=version_number, + status=status, + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + ) + self.db.add(metadata) + if commit: + self.db.commit() + self.db.refresh(metadata) + else: + self.db.flush() + return metadata + + def update_metadata( + self, + metadata: ToolMetadata, + *, + display_name: str, + domain: str, + description: str, + parameters_json: list[dict], + status: ToolLifecycleStatus, + author_staff_account_id: int, + author_display_name: str, + commit: bool = True, + ) -> ToolMetadata: + metadata.display_name = display_name + metadata.domain = domain + metadata.description = description + metadata.parameters_json = parameters_json + metadata.status = status + metadata.author_staff_account_id = author_staff_account_id + metadata.author_display_name = author_display_name + if commit: + self.db.commit() + self.db.refresh(metadata) + else: + self.db.flush() + return metadata + + def upsert_version_metadata( + self, + *, + draft_id: int, + tool_version_id: int, + tool_name: str, + display_name: str, + domain: str, + description: str, + parameters_json: list[dict], + version_number: int, + status: ToolLifecycleStatus, + author_staff_account_id: int, + author_display_name: str, + commit: bool = True, + ) -> ToolMetadata: + existing = self.get_by_tool_version_id(tool_version_id) + if existing is None: + return self.create( + draft_id=draft_id, + tool_version_id=tool_version_id, + tool_name=tool_name, + display_name=display_name, + domain=domain, + description=description, + parameters_json=parameters_json, + version_number=version_number, + status=status, + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + commit=commit, + ) + return self.update_metadata( + existing, + display_name=display_name, + domain=domain, + description=description, + parameters_json=parameters_json, + status=status, + author_staff_account_id=author_staff_account_id, + author_display_name=author_display_name, + commit=commit, + ) + + @staticmethod + def build_metadata_id(tool_name: str, version_number: int) -> str: + normalized_tool_name = str(tool_name or "").strip().lower() + return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}" diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index 5381760..1f32313 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -5,14 +5,25 @@ from dataclasses import dataclass from datetime import UTC, datetime from admin_app.core.settings import AdminSettings -from admin_app.db.models import ToolDraft, ToolVersion +from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion +from admin_app.db.models.tool_artifact import ( + ToolArtifactKind, + ToolArtifactStage, + ToolArtifactStatus, +) +from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository from admin_app.repositories.tool_draft_repository import ToolDraftRepository +from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository from shared.contracts import ( + GENERATED_TOOL_ENTRYPOINT, + GENERATED_TOOLS_PACKAGE, ServiceName, TOOL_LIFECYCLE_STAGES, ToolLifecycleStatus, ToolParameterType, + build_generated_tool_module_name, + build_generated_tool_module_path, ) @@ -196,6 +207,7 @@ _PARAMETER_TYPE_DESCRIPTIONS = { _TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") _PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") +_RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in _BOOTSTRAP_TOOL_CATALOG) class ToolManagementService: @@ -204,19 +216,26 @@ class ToolManagementService: settings: AdminSettings, draft_repository: ToolDraftRepository | None = None, version_repository: ToolVersionRepository | None = None, + metadata_repository: ToolMetadataRepository | None = None, + artifact_repository: ToolArtifactRepository | None = None, ): self.settings = settings self.draft_repository = draft_repository self.version_repository = version_repository + self.metadata_repository = metadata_repository + self.artifact_repository = artifact_repository def build_overview_payload(self) -> dict: - catalog = self.list_publication_catalog() + catalog_payload = self.build_publications_payload() + catalog = catalog_payload["publications"] persisted_draft_count = len(self.draft_repository.list_drafts()) if self.draft_repository else 0 persisted_version_count = 0 if self.version_repository is not None: persisted_version_count = len(self.version_repository.list_versions()) elif self.draft_repository is not None: persisted_version_count = sum(draft.version_count for draft in self.draft_repository.list_drafts()) + persisted_metadata_count = len(self.metadata_repository.list_metadata()) if self.metadata_repository else 0 + persisted_artifact_count = len(self.artifact_repository.list_artifacts()) if self.artifact_repository else 0 return { "mode": "admin_tool_draft_governance", "metrics": [ @@ -224,7 +243,7 @@ class ToolManagementService: "key": "active_catalog", "label": "Tools mapeadas", "value": str(len(catalog)), - "description": "Catalogo bootstrap refletindo a base de tools conhecida no monorepo.", + "description": "Catalogo governado persistido quando disponivel, com fallback bootstrap enquanto o admin ainda nao tiver metadados proprios.", }, { "key": "lifecycle_stages", @@ -250,6 +269,18 @@ class ToolManagementService: "value": str(persisted_version_count), "description": "Historico versionado das iteracoes de cada tool governada pelo admin.", }, + { + "key": "persisted_metadata", + "label": "Metadados persistidos", + "value": str(persisted_metadata_count), + "description": "Snapshots canonicos por versao com nome, descricao, parametros, status e autor da tool.", + }, + { + "key": "persisted_artifacts", + "label": "Artefatos auditaveis", + "value": str(persisted_artifact_count), + "description": "Manifestos de geracao e relatorios de validacao gravados por versao para trilha administrativa.", + }, ], "workflow": self.build_lifecycle_payload(), "next_steps": [ @@ -315,6 +346,7 @@ class ToolManagementService: ], "naming_rules": [ "tool_name deve usar snake_case minusculo, sem espacos, com 3 a 64 caracteres.", + "tool_name nao pode reutilizar nomes reservados pelo catalogo core ja publicado.", "display_name deve explicar claramente a acao operacional que o bot vai executar.", "Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.", ], @@ -370,6 +402,17 @@ class ToolManagementService: } def build_publications_payload(self) -> dict: + metadata_entries = self._list_latest_metadata_entries() + if metadata_entries: + return { + "source": "admin_metadata_catalog", + "target_service": ServiceName.PRODUCT, + "publications": [ + self._serialize_metadata_publication(metadata) + for metadata in metadata_entries + ], + } + return { "source": "bootstrap_catalog", "target_service": ServiceName.PRODUCT, @@ -478,6 +521,33 @@ class ToolManagementService: requires_director_approval=True, ) + if version is not None and self.metadata_repository is not None: + self.metadata_repository.upsert_version_metadata( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=draft.tool_name, + display_name=draft.display_name, + domain=draft.domain, + description=draft.description, + parameters_json=stored_parameters, + version_number=version.version_number, + status=version.status, + author_staff_account_id=version.owner_staff_account_id, + author_display_name=version.owner_display_name, + ) + + if version is not None and self.artifact_repository is not None: + self._persist_initial_version_artifacts( + draft=draft, + version=version, + summary=summary, + warnings=warnings, + stored_parameters=stored_parameters, + required_parameter_count=required_parameter_count, + owner_staff_account_id=owner_staff_account_id, + owner_name=owner_name or "Autor administrativo", + ) + return { "storage_status": "admin_database", "message": "Draft administrativo persistido com sucesso em fluxo versionado.", @@ -560,6 +630,150 @@ class ToolManagementService: for entry in _BOOTSTRAP_TOOL_CATALOG ] + def _persist_initial_version_artifacts( + self, + *, + draft: ToolDraft, + version: ToolVersion, + summary: str, + warnings: list[str], + stored_parameters: list[dict], + required_parameter_count: int, + owner_staff_account_id: int, + owner_name: str, + ) -> None: + if self.artifact_repository is None: + return + + generation_payload = self._build_generation_artifact_payload( + draft=draft, + version=version, + summary=summary, + stored_parameters=stored_parameters, + ) + validation_payload = self._build_validation_artifact_payload( + draft=draft, + version=version, + warnings=warnings, + stored_parameters=stored_parameters, + required_parameter_count=required_parameter_count, + ) + + self.artifact_repository.upsert_version_artifact( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=draft.tool_name, + version_number=version.version_number, + artifact_stage=ToolArtifactStage.GENERATION, + artifact_kind=ToolArtifactKind.GENERATION_REQUEST, + artifact_status=ToolArtifactStatus.PENDING, + summary="Manifesto inicial de geracao persistido para auditoria da versao.", + payload_json=generation_payload, + author_staff_account_id=owner_staff_account_id, + author_display_name=owner_name, + ) + self.artifact_repository.upsert_version_artifact( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=draft.tool_name, + version_number=version.version_number, + artifact_stage=ToolArtifactStage.VALIDATION, + artifact_kind=ToolArtifactKind.VALIDATION_REPORT, + artifact_status=ToolArtifactStatus.SUCCEEDED, + summary="Relatorio de validacao do pre-cadastro persistido para auditoria da versao.", + payload_json=validation_payload, + author_staff_account_id=owner_staff_account_id, + author_display_name=owner_name, + ) + + @staticmethod + def _build_generation_artifact_payload( + *, + draft: ToolDraft, + version: ToolVersion, + summary: str, + stored_parameters: list[dict], + ) -> dict: + return { + "source": "admin_draft_intake", + "tool_name": draft.tool_name, + "display_name": draft.display_name, + "domain": draft.domain, + "version_number": version.version_number, + "draft_id": draft.draft_id, + "version_id": version.version_id, + "business_goal": draft.business_goal, + "description": draft.description, + "summary": summary, + "parameters": list(stored_parameters), + "requires_director_approval": draft.requires_director_approval, + "target_package": GENERATED_TOOLS_PACKAGE, + "target_module": build_generated_tool_module_name(draft.tool_name), + "target_file_path": build_generated_tool_module_path(draft.tool_name), + "target_callable": GENERATED_TOOL_ENTRYPOINT, + "reserved_lifecycle_target": ToolLifecycleStatus.GENERATED.value, + } + + @staticmethod + def _build_validation_artifact_payload( + *, + draft: ToolDraft, + version: ToolVersion, + warnings: list[str], + stored_parameters: list[dict], + required_parameter_count: int, + ) -> dict: + return { + "source": "admin_draft_intake", + "tool_name": draft.tool_name, + "version_number": version.version_number, + "draft_id": draft.draft_id, + "version_id": version.version_id, + "validation_status": "passed", + "warnings": list(warnings), + "parameter_count": len(stored_parameters), + "required_parameter_count": required_parameter_count, + "checked_rules": [ + "tool_name_snake_case", + "display_name_min_length", + "domain_catalog", + "description_min_length", + "business_goal_min_length", + "parameter_contracts", + ], + } + + def _list_latest_metadata_entries(self) -> list[ToolMetadata]: + if self.metadata_repository is None: + return [] + + latest_by_tool_name: dict[str, ToolMetadata] = {} + for metadata in self.metadata_repository.list_metadata(): + normalized_tool_name = str(metadata.tool_name or "").strip().lower() + if normalized_tool_name in latest_by_tool_name: + continue + latest_by_tool_name[normalized_tool_name] = metadata + return list(latest_by_tool_name.values()) + + def _serialize_metadata_publication(self, metadata: ToolMetadata) -> dict: + parameters = self._serialize_parameters_for_response(metadata.parameters_json) + return { + "publication_id": metadata.metadata_id, + "tool_name": metadata.tool_name, + "display_name": metadata.display_name, + "description": metadata.description, + "domain": metadata.domain, + "version": metadata.version_number, + "status": metadata.status, + "parameter_count": len(parameters), + "parameters": parameters, + "author_name": metadata.author_display_name, + "implementation_module": build_generated_tool_module_name(metadata.tool_name), + "implementation_callable": GENERATED_TOOL_ENTRYPOINT, + "published_by": metadata.author_display_name, + "published_at": metadata.updated_at or metadata.created_at, + } + def _serialize_draft_summary(self, draft: ToolDraft) -> dict: return { "draft_id": draft.draft_id, @@ -654,6 +868,10 @@ class ToolManagementService: tool_name = str(payload.get("tool_name") or "").strip().lower() if not _TOOL_NAME_PATTERN.fullmatch(tool_name): raise ValueError("tool_name deve usar snake_case minusculo com 3 a 64 caracteres.") + if tool_name in _RESERVED_CORE_TOOL_NAMES: + raise ValueError( + "tool_name reservado pelo catalogo core do sistema. Gere uma nova tool sem sobrescrever uma capability interna." + ) display_name = str(payload.get("display_name") or "").strip() if len(display_name) < 4: diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index 38e7ac0..ea6f25e 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -224,7 +224,7 @@ function mountToolReviewBoard(board) { setText("[data-tool-review-publication-count]", String(items.length)); setText("[data-tool-publication-source]", payload?.source || "Catalogo web"); publicationList.innerHTML = items.length > 0 - ? items.slice(0, 9).map((item) => `
${escapeHtml(item.domain || "tool")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}

${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}

${escapeHtml(item.implementation_module || "")}
`).join("") + ? items.slice(0, 9).map((item) => `
${escapeHtml(item.domain || "tool")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}

${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}

Status: ${escapeHtml(item.status || "draft")}
Parametros: ${escapeHtml(String(item.parameter_count || 0))}
Autor: ${escapeHtml(item.author_name || item.published_by || "Nao informado")}
${escapeHtml(item.implementation_module || "")}
`).join("") : `

Catalogo ativo vazio

Nenhuma publicacao ativa retornada pela sessao web.

`; } diff --git a/app/db/bootstrap.py b/app/db/bootstrap.py index b25ba0a..d22cf02 100644 --- a/app/db/bootstrap.py +++ b/app/db/bootstrap.py @@ -3,6 +3,8 @@ Rotina dedicada de bootstrap de banco de dados. Cria tabelas e executa seed inicial de forma explicita, fora do startup do app. """ +from pathlib import Path + from sqlalchemy import inspect, text from app.core.settings import settings @@ -23,6 +25,22 @@ from app.db.mock_models import ( ) from app.db.mock_seed import seed_mock_data from app.db.tool_seed import seed_tools +from shared.contracts import GENERATED_TOOLS_PACKAGE + + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] + + +def _ensure_generated_tools_runtime_package() -> Path: + package_dir = _PROJECT_ROOT / GENERATED_TOOLS_PACKAGE + package_dir.mkdir(parents=True, exist_ok=True) + init_file = package_dir / "__init__.py" + if not init_file.exists(): + init_file.write_text( + '"""Isolated runtime package for admin-governed generated tools."""\n', + encoding="utf-8", + ) + return package_dir def _ensure_mock_schema_evolution() -> None: @@ -56,6 +74,13 @@ def bootstrap_databases( print("Inicializando bancos...") failures: list[str] = [] + try: + generated_tools_dir = _ensure_generated_tools_runtime_package() + print(f"Diretorio isolado de tools geradas pronto em {generated_tools_dir}.") + except Exception as exc: + print(f"Aviso: falha ao preparar diretorio isolado de tools geradas: {exc}") + failures.append(f"generated_tools={exc}") + should_seed_tools = settings.auto_seed_tools if run_tools_seed is None else bool(run_tools_seed) should_seed_mock = ( settings.auto_seed_mock and settings.mock_seed_enabled diff --git a/app/services/tools/tool_registry.py b/app/services/tools/tool_registry.py index 28cef6f..fbaf951 100644 --- a/app/services/tools/tool_registry.py +++ b/app/services/tools/tool_registry.py @@ -2,6 +2,7 @@ import inspect from typing import Callable, Dict, List from fastapi import HTTPException +from shared.contracts import GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE from sqlalchemy.orm import Session from app.models.tool_model import ToolDefinition @@ -42,6 +43,10 @@ HANDLERS: Dict[str, Callable] = { } +class GeneratedToolCoreBoundaryViolation(RuntimeError): + """Raised when a generated tool attempts to reuse or point at core runtime code.""" + + # Registry em memoria das tools disponiveis para o orquestrador. class ToolRegistry: @@ -66,6 +71,26 @@ class ToolRegistry: def register_tool(self, name, description, parameters, handler): """Registra uma tool em memoria para uso pelo orquestrador.""" + if self._is_generated_handler(handler): + self._ensure_generated_tool_boundary(name=name, handler=handler) + self._append_tool_definition( + name=name, + description=description, + parameters=parameters, + handler=handler, + ) + + def register_generated_tool(self, name, description, parameters, handler): + """Registra uma tool gerada apenas quando ela respeita o pacote isolado do runtime.""" + self._ensure_generated_tool_boundary(name=name, handler=handler) + self._append_tool_definition( + name=name, + description=description, + parameters=parameters, + handler=handler, + ) + + def _append_tool_definition(self, *, name, description, parameters, handler): self._tools.append( ToolDefinition( name=name, @@ -75,6 +100,35 @@ class ToolRegistry: ) ) + @staticmethod + def _is_generated_handler(handler: Callable) -> bool: + module_name = str(getattr(handler, "__module__", "") or "").strip() + return module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}.") + + def _ensure_generated_tool_boundary(self, *, name: str, handler: Callable) -> None: + normalized_name = str(name or "").strip().lower() + if normalized_name in HANDLERS: + raise GeneratedToolCoreBoundaryViolation( + f"Tool gerada '{normalized_name}' nao pode sobrescrever um handler do catalogo core." + ) + + if any(str(tool.name or "").strip().lower() == normalized_name for tool in self._tools): + raise GeneratedToolCoreBoundaryViolation( + f"Tool gerada '{normalized_name}' nao pode sobrescrever uma tool ja registrada no runtime." + ) + + module_name = str(getattr(handler, "__module__", "") or "").strip() + if not module_name.startswith(f"{GENERATED_TOOLS_PACKAGE}."): + raise GeneratedToolCoreBoundaryViolation( + f"Tools geradas so podem ser carregadas do pacote isolado '{GENERATED_TOOLS_PACKAGE}.*'." + ) + + handler_name = str(getattr(handler, "__name__", "") or "").strip() + if handler_name != GENERATED_TOOL_ENTRYPOINT: + raise GeneratedToolCoreBoundaryViolation( + f"Tools geradas precisam expor o entrypoint governado '{GENERATED_TOOL_ENTRYPOINT}'." + ) + def get_tools(self) -> List[ToolDefinition]: """Retorna a lista atual de tools registradas.""" return self._tools diff --git a/generated_tools/README.md b/generated_tools/README.md new file mode 100644 index 0000000..138b0f6 --- /dev/null +++ b/generated_tools/README.md @@ -0,0 +1,3 @@ +# Generated Tools + +Diretorio isolado para modulos publicados pelo fluxo administrativo de tools. diff --git a/generated_tools/__init__.py b/generated_tools/__init__.py new file mode 100644 index 0000000..8f649bd --- /dev/null +++ b/generated_tools/__init__.py @@ -0,0 +1 @@ +"""Isolated runtime package for admin-governed generated tools.""" diff --git a/shared/contracts/__init__.py b/shared/contracts/__init__.py index 7dd3369..c9aa8df 100644 --- a/shared/contracts/__init__.py +++ b/shared/contracts/__init__.py @@ -50,6 +50,8 @@ from shared.contracts.system_functional_configuration import ( get_functional_configuration, ) from shared.contracts.tool_publication import ( + GENERATED_TOOL_ENTRYPOINT, + GENERATED_TOOLS_PACKAGE, PublishedToolContract, ServiceName, TOOL_LIFECYCLE_STAGES, @@ -59,12 +61,16 @@ from shared.contracts.tool_publication import ( ToolParameterContract, ToolParameterType, ToolPublicationEnvelope, + build_generated_tool_module_name, + build_generated_tool_module_path, get_tool_lifecycle_stage, ) __all__ = [ "AdminPermission", "BOT_GOVERNED_SETTINGS", + "GENERATED_TOOL_ENTRYPOINT", + "GENERATED_TOOLS_PACKAGE", "MODEL_RUNTIME_PROFILES", "MODEL_RUNTIME_SEPARATION_RULES", "PRODUCT_OPERATIONAL_DATASETS", @@ -103,6 +109,8 @@ __all__ = [ "FunctionalConfigurationMutability", "FunctionalConfigurationPropagation", "FunctionalConfigurationSource", + "build_generated_tool_module_name", + "build_generated_tool_module_path", "get_bot_governed_setting", "get_functional_configuration", "get_model_runtime_contract", diff --git a/shared/contracts/tool_publication.py b/shared/contracts/tool_publication.py index 9bb0514..1b73a93 100644 --- a/shared/contracts/tool_publication.py +++ b/shared/contracts/tool_publication.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import datetime from enum import Enum +import re from pydantic import BaseModel, Field @@ -101,6 +102,28 @@ def get_tool_lifecycle_stage( return _TOOL_LIFECYCLE_STAGE_BY_STATUS[normalized_status] +GENERATED_TOOLS_PACKAGE = "generated_tools" +GENERATED_TOOL_ENTRYPOINT = "run" +_GENERATED_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") + + +def _normalize_generated_tool_name(tool_name: str) -> str: + normalized = str(tool_name or "").strip().lower() + if not _GENERATED_TOOL_NAME_PATTERN.match(normalized): + raise ValueError("tool_name must use lowercase snake_case to build the generated module path.") + return normalized + + +def build_generated_tool_module_name(tool_name: str) -> str: + normalized = _normalize_generated_tool_name(tool_name) + return f"{GENERATED_TOOLS_PACKAGE}.{normalized}" + + +def build_generated_tool_module_path(tool_name: str) -> str: + normalized = _normalize_generated_tool_name(tool_name) + return f"{GENERATED_TOOLS_PACKAGE}/{normalized}.py" + + class ToolParameterType(str, Enum): STRING = "string" INTEGER = "integer" diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index 5d1d25b..24a993e 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -9,9 +9,15 @@ from admin_app.api.dependencies import ( ) from admin_app.app_factory import create_app from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal -from admin_app.db.models import ToolDraft, ToolVersion +from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion +from admin_app.db.models.tool_artifact import ToolArtifactKind from admin_app.services import ToolManagementService -from shared.contracts import StaffRole, ToolLifecycleStatus +from shared.contracts import ( + GENERATED_TOOL_ENTRYPOINT, + StaffRole, + ToolLifecycleStatus, + build_generated_tool_module_name, +) class _FakeToolDraftRepository: @@ -76,7 +82,10 @@ class _FakeToolVersionRepository: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: versions = sorted( self.versions, - key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)), + key=lambda version: ( + version.version_number, + version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), reverse=True, ) if tool_name: @@ -113,12 +122,153 @@ class _FakeToolVersionRepository: return f"tool_version::{normalized}::v{int(version_number)}" +class _FakeToolMetadataRepository: + def __init__(self): + self.metadata_entries: list[ToolMetadata] = [] + self.next_id = 1 + + def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]: + metadata_entries = sorted( + self.metadata_entries, + key=lambda metadata: ( + metadata.version_number, + metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized] + if statuses: + allowed = set(statuses) + metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed] + return metadata_entries + + def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None: + for metadata in self.metadata_entries: + if metadata.tool_version_id == tool_version_id: + return metadata + return None + + def create(self, **kwargs) -> ToolMetadata: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 19, version_number, tzinfo=timezone.utc) + metadata = ToolMetadata( + id=self.next_id, + metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number), + created_at=now, + updated_at=now, + **kwargs, + ) + self.next_id += 1 + self.metadata_entries.append(metadata) + return metadata + + def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata: + metadata.display_name = kwargs["display_name"] + metadata.domain = kwargs["domain"] + metadata.description = kwargs["description"] + metadata.parameters_json = kwargs["parameters_json"] + metadata.status = kwargs["status"] + metadata.author_staff_account_id = kwargs["author_staff_account_id"] + metadata.author_display_name = kwargs["author_display_name"] + metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, tzinfo=timezone.utc) + return metadata + + def upsert_version_metadata(self, **kwargs) -> ToolMetadata: + existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) + if existing is None: + return self.create(**kwargs) + return self.update_metadata(existing, **kwargs) + + @staticmethod + def build_metadata_id(tool_name: str, version_number: int) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_metadata::{normalized}::v{int(version_number)}" + + +class _FakeToolArtifactRepository: + def __init__(self): + self.artifacts: list[ToolArtifact] = [] + self.next_id = 1 + + def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]: + artifacts = sorted( + self.artifacts, + key=lambda artifact: ( + artifact.version_number, + artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized] + if tool_version_id is not None: + artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id] + if artifact_stage: + artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage] + if artifact_kind: + artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind] + return artifacts + + def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None: + for artifact in self.artifacts: + if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind: + return artifact + return None + + def create(self, **kwargs) -> ToolArtifact: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 20, version_number, self.next_id, tzinfo=timezone.utc) + artifact = ToolArtifact( + id=self.next_id, + artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]), + created_at=now, + updated_at=now, + checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}", + **kwargs, + ) + self.next_id += 1 + self.artifacts.append(artifact) + return artifact + + def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: + artifact.artifact_status = kwargs["artifact_status"] + artifact.storage_kind = kwargs["storage_kind"] + artifact.summary = kwargs["summary"] + artifact.payload_json = kwargs["payload_json"] + artifact.checksum = kwargs["checksum"] + artifact.author_staff_account_id = kwargs["author_staff_account_id"] + artifact.author_display_name = kwargs["author_display_name"] + artifact.updated_at = datetime(2026, 3, 31, 20, artifact.version_number, tzinfo=timezone.utc) + return artifact + + def upsert_version_artifact(self, **kwargs) -> ToolArtifact: + existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"]) + if existing is None: + return self.create(**kwargs) + return self.update_artifact(existing, **kwargs) + + @staticmethod + def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}" + + class AdminPanelToolsWebTests(unittest.TestCase): def _build_client_with_role( self, role: StaffRole, settings: AdminSettings | None = None, - ) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]: + ) -> tuple[ + TestClient, + object, + _FakeToolDraftRepository, + _FakeToolVersionRepository, + _FakeToolMetadataRepository, + _FakeToolArtifactRepository, + ]: app = create_app( settings or AdminSettings( @@ -128,10 +278,14 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) draft_repository = _FakeToolDraftRepository() version_repository = _FakeToolVersionRepository() + metadata_repository = _FakeToolMetadataRepository() + artifact_repository = _FakeToolArtifactRepository() service = ToolManagementService( settings=app.state.admin_settings, draft_repository=draft_repository, version_repository=version_repository, + metadata_repository=metadata_repository, + artifact_repository=artifact_repository, ) app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal( id=21, @@ -141,10 +295,10 @@ class AdminPanelToolsWebTests(unittest.TestCase): is_active=True, ) app.dependency_overrides[get_tool_management_service] = lambda: service - return TestClient(app), app, draft_repository, version_repository + return TestClient(app), app, draft_repository, version_repository, metadata_repository, artifact_repository def test_panel_tools_overview_is_available_for_colaborador_session(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/panel/tools/overview") finally: @@ -154,11 +308,37 @@ class AdminPanelToolsWebTests(unittest.TestCase): payload = response.json() self.assertEqual(payload["mode"], "admin_tool_draft_governance") self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]]) + self.assertIn("persisted_metadata", [item["key"] for item in payload["metrics"]]) + self.assertIn("persisted_artifacts", [item["key"] for item in payload["metrics"]]) self.assertIn("/admin/panel/tools/contracts", [item["href"] for item in payload["actions"]]) self.assertIn("/admin/panel/tools/drafts/intake", [item["href"] for item in payload["actions"]]) - def test_panel_tool_intake_persists_draft_with_version_metadata_for_colaborador(self): - client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR) + def test_panel_tool_intake_blocks_tool_name_reserved_by_core_catalog_for_colaborador(self): + client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "vendas", + "tool_name": "consultar_estoque", + "display_name": "Consultar estoque paralelo", + "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.", + "business_goal": "Validar que o painel bloqueia nomes reservados do runtime core.", + "parameters": [], + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 422) + self.assertIn("catalogo core do sistema", response.json()["detail"]) + self.assertEqual(len(draft_repository.drafts), 0) + self.assertEqual(len(version_repository.versions), 0) + self.assertEqual(len(metadata_repository.metadata_entries), 0) + self.assertEqual(len(artifact_repository.artifacts), 0) + + def test_panel_tool_intake_persists_draft_with_version_metadata_and_artifacts_for_colaborador(self): + client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.post( "/admin/panel/tools/drafts/intake", @@ -199,9 +379,13 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(len(payload["draft_preview"]["parameters"]), 2) self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(version_repository.versions), 1) + self.assertEqual(len(metadata_repository.metadata_entries), 1) + self.assertEqual(len(artifact_repository.artifacts), 2) + artifact_kinds = {artifact.artifact_kind for artifact in artifact_repository.artifacts} + self.assertEqual(artifact_kinds, {ToolArtifactKind.GENERATION_REQUEST, ToolArtifactKind.VALIDATION_REPORT}) def test_panel_drafts_list_returns_single_root_item_after_new_version(self): - client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR) try: client.post( "/admin/panel/tools/drafts/intake", @@ -238,9 +422,11 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(payload["drafts"][0]["version_count"], 2) self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(version_repository.versions), 2) + self.assertEqual(len(metadata_repository.metadata_entries), 2) + self.assertEqual(len(artifact_repository.artifacts), 4) def test_panel_tools_review_queue_requires_director_session(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/panel/tools/review-queue") finally: @@ -253,7 +439,7 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) def test_panel_tools_review_queue_is_available_for_diretor_session(self): - client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/panel/tools/review-queue") finally: @@ -263,7 +449,7 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state") def test_panel_tools_publications_require_director_publication_permission(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/panel/tools/publications") finally: @@ -276,7 +462,7 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) def test_panel_tools_publications_return_catalog_for_diretor_session(self): - client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/panel/tools/publications") finally: @@ -284,10 +470,51 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) payload = response.json() + self.assertEqual(payload["source"], "bootstrap_catalog") self.assertEqual(payload["target_service"], "product") self.assertGreaterEqual(len(payload["publications"]), 10) self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]]) + def test_panel_tools_publications_prefer_persisted_metadata_for_diretor_session(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [ + { + "name": "contrato_id", + "parameter_type": "string", + "description": "Identificador do contrato consultado.", + "required": True, + } + ], + }, + ) + response = client.get("/admin/panel/tools/publications") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["source"], "admin_metadata_catalog") + self.assertEqual(len(payload["publications"]), 1) + publication = payload["publications"][0] + self.assertEqual(publication["publication_id"], "tool_metadata::emitir_resumo_locacao::v1") + self.assertEqual(publication["tool_name"], "emitir_resumo_locacao") + self.assertEqual(publication["status"], "draft") + self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("emitir_resumo_locacao")) + self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(publication["parameter_count"], 1) + self.assertEqual(publication["author_name"], "Equipe Web") + self.assertEqual(publication["parameters"][0]["name"], "contrato_id") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_tool_artifact_model.py b/tests/test_admin_tool_artifact_model.py new file mode 100644 index 0000000..324a19c --- /dev/null +++ b/tests/test_admin_tool_artifact_model.py @@ -0,0 +1,61 @@ +import unittest + +from admin_app.db.models import ToolArtifact +from admin_app.db.models.tool_artifact import ( + ToolArtifactKind, + ToolArtifactStage, + ToolArtifactStatus, + ToolArtifactStorageKind, +) + + +class ToolArtifactModelTests(unittest.TestCase): + def test_tool_artifact_declares_expected_table_and_columns(self): + self.assertEqual(ToolArtifact.__tablename__, "tool_artifacts") + self.assertIn("artifact_id", ToolArtifact.__table__.columns) + self.assertIn("draft_id", ToolArtifact.__table__.columns) + self.assertIn("tool_version_id", ToolArtifact.__table__.columns) + self.assertIn("tool_name", ToolArtifact.__table__.columns) + self.assertIn("version_number", ToolArtifact.__table__.columns) + self.assertIn("artifact_stage", ToolArtifact.__table__.columns) + self.assertIn("artifact_kind", ToolArtifact.__table__.columns) + self.assertIn("artifact_status", ToolArtifact.__table__.columns) + self.assertIn("storage_kind", ToolArtifact.__table__.columns) + self.assertIn("summary", ToolArtifact.__table__.columns) + self.assertIn("payload_json", ToolArtifact.__table__.columns) + self.assertIn("checksum", ToolArtifact.__table__.columns) + self.assertIn("author_staff_account_id", ToolArtifact.__table__.columns) + self.assertIn("author_display_name", ToolArtifact.__table__.columns) + + def test_tool_artifact_uses_expected_constraints_and_defaults(self): + draft_foreign_keys = list(ToolArtifact.__table__.columns["draft_id"].foreign_keys) + self.assertEqual(len(draft_foreign_keys), 1) + self.assertEqual(str(draft_foreign_keys[0].target_fullname), "tool_drafts.id") + + version_foreign_keys = list(ToolArtifact.__table__.columns["tool_version_id"].foreign_keys) + self.assertEqual(len(version_foreign_keys), 1) + self.assertEqual(str(version_foreign_keys[0].target_fullname), "tool_versions.id") + + author_foreign_keys = list(ToolArtifact.__table__.columns["author_staff_account_id"].foreign_keys) + self.assertEqual(len(author_foreign_keys), 1) + self.assertEqual(str(author_foreign_keys[0].target_fullname), "staff_accounts.id") + + status_column = ToolArtifact.__table__.columns["artifact_status"] + self.assertEqual(status_column.default.arg, ToolArtifactStatus.PENDING) + self.assertEqual(status_column.type.process_bind_param("succeeded", None), "succeeded") + self.assertEqual(status_column.type.process_result_value("pending", None), ToolArtifactStatus.PENDING) + + stage_column = ToolArtifact.__table__.columns["artifact_stage"] + kind_column = ToolArtifact.__table__.columns["artifact_kind"] + storage_column = ToolArtifact.__table__.columns["storage_kind"] + self.assertEqual(stage_column.type.process_bind_param("validation", None), "validation") + self.assertEqual(kind_column.type.process_result_value("generation_request", None), ToolArtifactKind.GENERATION_REQUEST) + self.assertEqual(storage_column.default.arg, ToolArtifactStorageKind.INLINE_JSON) + self.assertEqual(storage_column.type.process_result_value("inline_json", None), ToolArtifactStorageKind.INLINE_JSON) + + unique_constraints = {constraint.name for constraint in ToolArtifact.__table__.constraints} + self.assertIn("uq_tool_artifacts_tool_version_kind", unique_constraints) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py index 0273d91..4b968f0 100644 --- a/tests/test_admin_tool_management_service.py +++ b/tests/test_admin_tool_management_service.py @@ -2,9 +2,17 @@ import unittest from datetime import datetime, timezone from admin_app.core import AdminSettings -from admin_app.db.models import ToolDraft, ToolVersion +from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion +from admin_app.db.models.tool_artifact import ToolArtifactKind from admin_app.services.tool_management_service import ToolManagementService -from shared.contracts import ToolLifecycleStatus +from shared.contracts import ( + GENERATED_TOOL_ENTRYPOINT, + GENERATED_TOOLS_PACKAGE, + ToolLifecycleStatus, + ToolParameterType, + build_generated_tool_module_name, + build_generated_tool_module_path, +) class _FakeToolDraftRepository: @@ -116,7 +124,10 @@ class _FakeToolVersionRepository: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: versions = sorted( self.versions, - key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)), + key=lambda version: ( + version.version_number, + version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), reverse=True, ) if tool_name: @@ -179,17 +190,155 @@ class _FakeToolVersionRepository: return f"tool_version::{normalized}::v{int(version_number)}" +class _FakeToolMetadataRepository: + def __init__(self): + self.metadata_entries: list[ToolMetadata] = [] + self.next_id = 1 + + def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]: + metadata_entries = sorted( + self.metadata_entries, + key=lambda metadata: ( + metadata.version_number, + metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized] + if statuses: + allowed = set(statuses) + metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed] + return metadata_entries + + def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None: + for metadata in self.metadata_entries: + if metadata.tool_version_id == tool_version_id: + return metadata + return None + + def create(self, **kwargs) -> ToolMetadata: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc) + metadata = ToolMetadata( + id=self.next_id, + metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number), + created_at=now, + updated_at=now, + **kwargs, + ) + self.next_id += 1 + self.metadata_entries.append(metadata) + return metadata + + def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata: + metadata.display_name = kwargs["display_name"] + metadata.domain = kwargs["domain"] + metadata.description = kwargs["description"] + metadata.parameters_json = kwargs["parameters_json"] + metadata.status = kwargs["status"] + metadata.author_staff_account_id = kwargs["author_staff_account_id"] + metadata.author_display_name = kwargs["author_display_name"] + metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, tzinfo=timezone.utc) + return metadata + + def upsert_version_metadata(self, **kwargs) -> ToolMetadata: + existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) + if existing is None: + return self.create(**kwargs) + return self.update_metadata(existing, **kwargs) + + @staticmethod + def build_metadata_id(tool_name: str, version_number: int) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_metadata::{normalized}::v{int(version_number)}" + + +class _FakeToolArtifactRepository: + def __init__(self): + self.artifacts: list[ToolArtifact] = [] + self.next_id = 1 + + def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]: + artifacts = sorted( + self.artifacts, + key=lambda artifact: ( + artifact.version_number, + artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized] + if tool_version_id is not None: + artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id] + if artifact_stage: + artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage] + if artifact_kind: + artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind] + return artifacts + + def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None: + for artifact in self.artifacts: + if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind: + return artifact + return None + + def create(self, **kwargs) -> ToolArtifact: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 18, version_number, self.next_id, tzinfo=timezone.utc) + artifact = ToolArtifact( + id=self.next_id, + artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]), + created_at=now, + updated_at=now, + checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}", + **kwargs, + ) + self.next_id += 1 + self.artifacts.append(artifact) + return artifact + + def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: + artifact.artifact_status = kwargs["artifact_status"] + artifact.storage_kind = kwargs["storage_kind"] + artifact.summary = kwargs["summary"] + artifact.payload_json = kwargs["payload_json"] + artifact.checksum = kwargs["checksum"] + artifact.author_staff_account_id = kwargs["author_staff_account_id"] + artifact.author_display_name = kwargs["author_display_name"] + artifact.updated_at = datetime(2026, 3, 31, 18, artifact.version_number, tzinfo=timezone.utc) + return artifact + + def upsert_version_artifact(self, **kwargs) -> ToolArtifact: + existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"]) + if existing is None: + return self.create(**kwargs) + return self.update_artifact(existing, **kwargs) + + @staticmethod + def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}" + + class AdminToolManagementServiceTests(unittest.TestCase): def setUp(self): self.draft_repository = _FakeToolDraftRepository() self.version_repository = _FakeToolVersionRepository() + self.metadata_repository = _FakeToolMetadataRepository() + self.artifact_repository = _FakeToolArtifactRepository() self.service = ToolManagementService( settings=AdminSettings(admin_api_prefix="/admin"), draft_repository=self.draft_repository, version_repository=self.version_repository, + metadata_repository=self.metadata_repository, + artifact_repository=self.artifact_repository, ) - def test_create_draft_submission_persists_initial_tool_version(self): + def test_create_draft_submission_persists_initial_tool_version_metadata_and_artifacts(self): payload = self.service.create_draft_submission( { "domain": "vendas", @@ -225,6 +374,56 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna") self.assertEqual(len(self.draft_repository.drafts), 1) self.assertEqual(len(self.version_repository.versions), 1) + self.assertEqual(len(self.metadata_repository.metadata_entries), 1) + self.assertEqual(self.metadata_repository.metadata_entries[0].author_display_name, "Equipe Interna") + self.assertEqual(self.metadata_repository.metadata_entries[0].version_number, 1) + self.assertEqual(len(self.artifact_repository.artifacts), 2) + + artifacts_by_kind = { + artifact.artifact_kind: artifact + for artifact in self.artifact_repository.artifacts + } + generation_artifact = artifacts_by_kind[ToolArtifactKind.GENERATION_REQUEST] + validation_artifact = artifacts_by_kind[ToolArtifactKind.VALIDATION_REPORT] + + self.assertEqual(generation_artifact.tool_name, "consultar_vendas_periodo") + self.assertEqual(generation_artifact.payload_json["target_package"], GENERATED_TOOLS_PACKAGE) + self.assertEqual( + generation_artifact.payload_json["target_module"], + build_generated_tool_module_name("consultar_vendas_periodo"), + ) + self.assertEqual( + generation_artifact.payload_json["target_file_path"], + build_generated_tool_module_path("consultar_vendas_periodo"), + ) + self.assertEqual(generation_artifact.payload_json["target_callable"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(generation_artifact.payload_json["reserved_lifecycle_target"], "generated") + self.assertEqual(generation_artifact.payload_json["parameters"][0]["name"], "periodo_inicio") + self.assertEqual(validation_artifact.payload_json["validation_status"], "passed") + self.assertEqual(validation_artifact.payload_json["parameter_count"], 2) + self.assertEqual(validation_artifact.author_display_name, "Equipe Interna") + self.assertTrue(generation_artifact.checksum) + self.assertTrue(validation_artifact.checksum) + + def test_create_draft_submission_blocks_tool_name_reserved_by_core_catalog(self): + with self.assertRaisesRegex(ValueError, "catalogo core do sistema"): + self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_estoque", + "display_name": "Consultar estoque paralelo", + "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.", + "business_goal": "Validar que o admin nao permite reutilizar nomes reservados do runtime core.", + "parameters": [], + }, + owner_staff_account_id=11, + owner_name="Equipe Interna", + ) + + self.assertEqual(len(self.draft_repository.drafts), 0) + self.assertEqual(len(self.version_repository.versions), 0) + self.assertEqual(len(self.metadata_repository.metadata_entries), 0) + self.assertEqual(len(self.artifact_repository.artifacts), 0) def test_create_draft_submission_reuses_root_draft_and_increments_version(self): self.service.create_draft_submission( @@ -265,6 +464,8 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["draft_preview"]["version_count"], 2) self.assertEqual(len(self.draft_repository.drafts), 1) self.assertEqual(len(self.version_repository.versions), 2) + self.assertEqual(len(self.metadata_repository.metadata_entries), 2) + self.assertEqual(len(self.artifact_repository.artifacts), 4) self.assertEqual(self.draft_repository.drafts[0].current_version_number, 2) self.assertEqual(self.draft_repository.drafts[0].version_count, 2) self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao") @@ -305,6 +506,43 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(payload["drafts"][0]["owner_name"], "Diretoria Comercial") self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT]) + def test_build_publications_payload_prefers_persisted_metadata_catalog(self): + self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + ) + + payload = self.service.build_publications_payload() + + self.assertEqual(payload["source"], "admin_metadata_catalog") + self.assertEqual(payload["target_service"], "product") + self.assertEqual(len(payload["publications"]), 1) + publication = payload["publications"][0] + self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1") + self.assertEqual(publication["tool_name"], "consultar_revisao_aberta") + self.assertEqual(publication["version"], 1) + self.assertEqual(publication["status"], ToolLifecycleStatus.DRAFT) + self.assertEqual(publication["author_name"], "Operacao de Oficina") + self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta")) + self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(publication["parameter_count"], 1) + self.assertEqual(publication["parameters"][0]["parameter_type"], ToolParameterType.STRING) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_tool_metadata_model.py b/tests/test_admin_tool_metadata_model.py new file mode 100644 index 0000000..64015a9 --- /dev/null +++ b/tests/test_admin_tool_metadata_model.py @@ -0,0 +1,49 @@ +import unittest + +from admin_app.db.models import ToolMetadata +from shared.contracts import ToolLifecycleStatus + + +class ToolMetadataModelTests(unittest.TestCase): + def test_tool_metadata_declares_expected_table_and_columns(self): + self.assertEqual(ToolMetadata.__tablename__, "tool_metadata") + self.assertIn("metadata_id", ToolMetadata.__table__.columns) + self.assertIn("draft_id", ToolMetadata.__table__.columns) + self.assertIn("tool_version_id", ToolMetadata.__table__.columns) + self.assertIn("tool_name", ToolMetadata.__table__.columns) + self.assertIn("display_name", ToolMetadata.__table__.columns) + self.assertIn("domain", ToolMetadata.__table__.columns) + self.assertIn("description", ToolMetadata.__table__.columns) + self.assertIn("parameters_json", ToolMetadata.__table__.columns) + self.assertIn("version_number", ToolMetadata.__table__.columns) + self.assertIn("status", ToolMetadata.__table__.columns) + self.assertIn("author_staff_account_id", ToolMetadata.__table__.columns) + self.assertIn("author_display_name", ToolMetadata.__table__.columns) + self.assertIn("created_at", ToolMetadata.__table__.columns) + self.assertIn("updated_at", ToolMetadata.__table__.columns) + + def test_tool_metadata_uses_expected_constraints_and_defaults(self): + draft_foreign_keys = list(ToolMetadata.__table__.columns["draft_id"].foreign_keys) + self.assertEqual(len(draft_foreign_keys), 1) + self.assertEqual(str(draft_foreign_keys[0].target_fullname), "tool_drafts.id") + + version_foreign_keys = list(ToolMetadata.__table__.columns["tool_version_id"].foreign_keys) + self.assertEqual(len(version_foreign_keys), 1) + self.assertEqual(str(version_foreign_keys[0].target_fullname), "tool_versions.id") + + author_foreign_keys = list(ToolMetadata.__table__.columns["author_staff_account_id"].foreign_keys) + self.assertEqual(len(author_foreign_keys), 1) + self.assertEqual(str(author_foreign_keys[0].target_fullname), "staff_accounts.id") + + status_column = ToolMetadata.__table__.columns["status"] + self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT) + self.assertEqual(status_column.type.process_bind_param("archived", None), "archived") + self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT) + + unique_constraints = {constraint.name for constraint in ToolMetadata.__table__.constraints} + self.assertIn("uq_tool_metadata_tool_name_version_number", unique_constraints) + self.assertTrue(ToolMetadata.__table__.columns["tool_version_id"].unique) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index f738f13..a2f63fd 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -6,9 +6,15 @@ from fastapi.testclient import TestClient from admin_app.api.dependencies import get_current_staff_principal, get_tool_management_service from admin_app.app_factory import create_app from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal -from admin_app.db.models import ToolDraft, ToolVersion +from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion +from admin_app.db.models.tool_artifact import ToolArtifactKind from admin_app.services import ToolManagementService -from shared.contracts import StaffRole, ToolLifecycleStatus +from shared.contracts import ( + GENERATED_TOOL_ENTRYPOINT, + StaffRole, + ToolLifecycleStatus, + build_generated_tool_module_name, +) class _FakeToolDraftRepository: @@ -73,7 +79,10 @@ class _FakeToolVersionRepository: def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]: versions = sorted( self.versions, - key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)), + key=lambda version: ( + version.version_number, + version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), reverse=True, ) if tool_name: @@ -110,12 +119,153 @@ class _FakeToolVersionRepository: return f"tool_version::{normalized}::v{int(version_number)}" +class _FakeToolMetadataRepository: + def __init__(self): + self.metadata_entries: list[ToolMetadata] = [] + self.next_id = 1 + + def list_metadata(self, *, tool_name=None, statuses=None) -> list[ToolMetadata]: + metadata_entries = sorted( + self.metadata_entries, + key=lambda metadata: ( + metadata.version_number, + metadata.updated_at or metadata.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + metadata_entries = [metadata for metadata in metadata_entries if metadata.tool_name == normalized] + if statuses: + allowed = set(statuses) + metadata_entries = [metadata for metadata in metadata_entries if metadata.status in allowed] + return metadata_entries + + def get_by_tool_version_id(self, tool_version_id: int) -> ToolMetadata | None: + for metadata in self.metadata_entries: + if metadata.tool_version_id == tool_version_id: + return metadata + return None + + def create(self, **kwargs) -> ToolMetadata: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc) + metadata = ToolMetadata( + id=self.next_id, + metadata_id=self.build_metadata_id(kwargs["tool_name"], version_number), + created_at=now, + updated_at=now, + **kwargs, + ) + self.next_id += 1 + self.metadata_entries.append(metadata) + return metadata + + def update_metadata(self, metadata: ToolMetadata, **kwargs) -> ToolMetadata: + metadata.display_name = kwargs["display_name"] + metadata.domain = kwargs["domain"] + metadata.description = kwargs["description"] + metadata.parameters_json = kwargs["parameters_json"] + metadata.status = kwargs["status"] + metadata.author_staff_account_id = kwargs["author_staff_account_id"] + metadata.author_display_name = kwargs["author_display_name"] + metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, tzinfo=timezone.utc) + return metadata + + def upsert_version_metadata(self, **kwargs) -> ToolMetadata: + existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) + if existing is None: + return self.create(**kwargs) + return self.update_metadata(existing, **kwargs) + + @staticmethod + def build_metadata_id(tool_name: str, version_number: int) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_metadata::{normalized}::v{int(version_number)}" + + +class _FakeToolArtifactRepository: + def __init__(self): + self.artifacts: list[ToolArtifact] = [] + self.next_id = 1 + + def list_artifacts(self, *, tool_name=None, tool_version_id=None, artifact_stage=None, artifact_kind=None) -> list[ToolArtifact]: + artifacts = sorted( + self.artifacts, + key=lambda artifact: ( + artifact.version_number, + artifact.updated_at or artifact.created_at or datetime.min.replace(tzinfo=timezone.utc), + ), + reverse=True, + ) + if tool_name: + normalized = str(tool_name).strip().lower() + artifacts = [artifact for artifact in artifacts if artifact.tool_name == normalized] + if tool_version_id is not None: + artifacts = [artifact for artifact in artifacts if artifact.tool_version_id == tool_version_id] + if artifact_stage: + artifacts = [artifact for artifact in artifacts if artifact.artifact_stage == artifact_stage] + if artifact_kind: + artifacts = [artifact for artifact in artifacts if artifact.artifact_kind == artifact_kind] + return artifacts + + def get_by_tool_version_and_kind(self, tool_version_id: int, artifact_kind) -> ToolArtifact | None: + for artifact in self.artifacts: + if artifact.tool_version_id == tool_version_id and artifact.artifact_kind == artifact_kind: + return artifact + return None + + def create(self, **kwargs) -> ToolArtifact: + version_number = kwargs["version_number"] + now = datetime(2026, 3, 31, 19, version_number, self.next_id, tzinfo=timezone.utc) + artifact = ToolArtifact( + id=self.next_id, + artifact_id=self.build_artifact_id(kwargs["tool_name"], version_number, kwargs["artifact_kind"]), + created_at=now, + updated_at=now, + checksum=kwargs.get("checksum") or f"fake-checksum-{self.next_id}", + **kwargs, + ) + self.next_id += 1 + self.artifacts.append(artifact) + return artifact + + def update_artifact(self, artifact: ToolArtifact, **kwargs) -> ToolArtifact: + artifact.artifact_status = kwargs["artifact_status"] + artifact.storage_kind = kwargs["storage_kind"] + artifact.summary = kwargs["summary"] + artifact.payload_json = kwargs["payload_json"] + artifact.checksum = kwargs["checksum"] + artifact.author_staff_account_id = kwargs["author_staff_account_id"] + artifact.author_display_name = kwargs["author_display_name"] + artifact.updated_at = datetime(2026, 3, 31, 19, artifact.version_number, tzinfo=timezone.utc) + return artifact + + def upsert_version_artifact(self, **kwargs) -> ToolArtifact: + existing = self.get_by_tool_version_and_kind(kwargs["tool_version_id"], kwargs["artifact_kind"]) + if existing is None: + return self.create(**kwargs) + return self.update_artifact(existing, **kwargs) + + @staticmethod + def build_artifact_id(tool_name: str, version_number: int, artifact_kind) -> str: + normalized = str(tool_name or "").strip().lower() + return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}" + + class AdminToolsWebTests(unittest.TestCase): def _build_client_with_role( self, role: StaffRole, settings: AdminSettings | None = None, - ) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]: + ) -> tuple[ + TestClient, + object, + _FakeToolDraftRepository, + _FakeToolVersionRepository, + _FakeToolMetadataRepository, + _FakeToolArtifactRepository, + ]: app = create_app( settings or AdminSettings( @@ -125,10 +275,14 @@ class AdminToolsWebTests(unittest.TestCase): ) draft_repository = _FakeToolDraftRepository() version_repository = _FakeToolVersionRepository() + metadata_repository = _FakeToolMetadataRepository() + artifact_repository = _FakeToolArtifactRepository() service = ToolManagementService( settings=app.state.admin_settings, draft_repository=draft_repository, version_repository=version_repository, + metadata_repository=metadata_repository, + artifact_repository=artifact_repository, ) app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( id=11, @@ -138,10 +292,10 @@ class AdminToolsWebTests(unittest.TestCase): is_active=True, ) app.dependency_overrides[get_tool_management_service] = lambda: service - return TestClient(app), app, draft_repository, version_repository + return TestClient(app), app, draft_repository, version_repository, metadata_repository, artifact_repository def test_tools_overview_returns_metrics_workflow_and_actions_for_colaborador(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"}) finally: @@ -153,13 +307,15 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["mode"], "admin_tool_draft_governance") self.assertEqual(payload["metrics"][0]["value"], "18") self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]]) + self.assertIn("persisted_metadata", [item["key"] for item in payload["metrics"]]) + self.assertIn("persisted_artifacts", [item["key"] for item in payload["metrics"]]) self.assertIn("active", [item["code"] for item in payload["workflow"]]) self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]]) self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]]) self.assertIn("artefatos", payload["next_steps"][0].lower()) def test_tools_contracts_return_shared_contract_snapshot(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"}) finally: @@ -176,8 +332,33 @@ class AdminToolsWebTests(unittest.TestCase): self.assertIn("string", [item["code"] for item in payload["parameter_types"]]) self.assertIn("published_tool", payload["publication_fields"]) + def test_tools_draft_intake_blocks_tool_name_reserved_by_core_catalog(self): + client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "vendas", + "tool_name": "consultar_estoque", + "display_name": "Consultar estoque paralelo", + "description": "Tentativa de sobrescrever a tool core de estoque com uma versao administrativa.", + "business_goal": "Validar que a API bloqueia nomes reservados do runtime core.", + "parameters": [], + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 422) + self.assertIn("catalogo core do sistema", response.json()["detail"]) + self.assertEqual(len(draft_repository.drafts), 0) + self.assertEqual(len(version_repository.versions), 0) + self.assertEqual(len(metadata_repository.metadata_entries), 0) + self.assertEqual(len(artifact_repository.artifacts), 0) + def test_tools_drafts_return_single_root_draft_with_current_version_after_reintake(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: first_response = client.post( "/admin/tools/drafts/intake", @@ -218,8 +399,8 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["drafts"][0]["version_count"], 2) self.assertEqual(payload["supported_statuses"], ["draft"]) - def test_tools_draft_intake_persists_admin_draft_with_version_metadata(self): - client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR) + def test_tools_draft_intake_persists_admin_draft_with_version_metadata_and_artifacts(self): + client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.post( "/admin/tools/drafts/intake", @@ -256,9 +437,13 @@ class AdminToolsWebTests(unittest.TestCase): self.assertGreaterEqual(len(payload["warnings"]), 1) self.assertEqual(len(draft_repository.drafts), 1) self.assertEqual(len(version_repository.versions), 1) + self.assertEqual(len(metadata_repository.metadata_entries), 1) + self.assertEqual(len(artifact_repository.artifacts), 2) + artifact_kinds = {artifact.artifact_kind for artifact in artifact_repository.artifacts} + self.assertEqual(artifact_kinds, {ToolArtifactKind.GENERATION_REQUEST, ToolArtifactKind.VALIDATION_REPORT}) def test_tools_review_queue_requires_director_review_permission(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) finally: @@ -271,7 +456,7 @@ class AdminToolsWebTests(unittest.TestCase): ) def test_tools_review_queue_is_available_for_diretor(self): - client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) finally: @@ -284,7 +469,7 @@ class AdminToolsWebTests(unittest.TestCase): self.assertIn("validated", payload["supported_statuses"]) def test_tools_publications_require_director_publication_permission(self): - client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) finally: @@ -297,7 +482,7 @@ class AdminToolsWebTests(unittest.TestCase): ) def test_tools_publications_return_bootstrap_catalog_for_diretor(self): - client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) finally: @@ -313,6 +498,48 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(first["status"], "active") self.assertEqual(first["implementation_module"], "app.services.tools.handlers") + def test_tools_publications_prefer_persisted_metadata_after_intake(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + ) + response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["source"], "admin_metadata_catalog") + self.assertEqual(len(payload["publications"]), 1) + publication = payload["publications"][0] + self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1") + self.assertEqual(publication["tool_name"], "consultar_revisao_aberta") + self.assertEqual(publication["status"], "draft") + self.assertEqual(publication["parameter_count"], 1) + self.assertEqual(publication["author_name"], "Equipe de Tools") + self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta")) + self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT) + self.assertEqual(publication["parameters"][0]["name"], "placa") + self.assertEqual(publication["parameters"][0]["parameter_type"], "string") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_write_governance.py b/tests/test_admin_write_governance.py index cd74d6a..0c9b78d 100644 --- a/tests/test_admin_write_governance.py +++ b/tests/test_admin_write_governance.py @@ -20,7 +20,15 @@ class AdminWriteGovernanceTests(unittest.TestCase): self.assertEqual(payload["mode"], "admin_internal_tables_only") self.assertEqual( payload["allowed_direct_write_tables"], - ["admin_audit_logs", "staff_accounts", "staff_sessions", "tool_drafts", "tool_versions"], + [ + "admin_audit_logs", + "staff_accounts", + "staff_sessions", + "tool_drafts", + "tool_versions", + "tool_metadata", + "tool_artifacts", + ], ) self.assertIn("sales_orders", payload["blocked_operational_dataset_keys"]) self.assertIn("orders", payload["blocked_product_source_tables"]) @@ -34,6 +42,8 @@ class AdminWriteGovernanceTests(unittest.TestCase): ensure_direct_admin_write_allowed("admin_audit_logs") ensure_direct_admin_write_allowed("tool_drafts") ensure_direct_admin_write_allowed("tool_versions") + ensure_direct_admin_write_allowed("tool_metadata") + ensure_direct_admin_write_allowed("tool_artifacts") def test_unknown_or_product_tables_raise_governance_violation(self): with self.assertRaises(AdminWriteGovernanceViolation): @@ -44,7 +54,12 @@ class AdminWriteGovernanceTests(unittest.TestCase): def test_session_guard_accepts_only_internal_admin_tables(self): enforce_admin_session_write_governance( - new=(_FakeTabledObject("staff_accounts"), _FakeTabledObject("tool_versions")), + new=( + _FakeTabledObject("staff_accounts"), + _FakeTabledObject("tool_versions"), + _FakeTabledObject("tool_metadata"), + _FakeTabledObject("tool_artifacts"), + ), dirty=(_FakeTabledObject("staff_sessions"),), deleted=( _FakeTabledObject("admin_audit_logs"), diff --git a/tests/test_conversation_adjustments.py b/tests/test_conversation_adjustments.py index bf62cb2..59c48c5 100644 --- a/tests/test_conversation_adjustments.py +++ b/tests/test_conversation_adjustments.py @@ -16,7 +16,7 @@ from app.models.tool_model import ToolDefinition from app.services.orchestration.conversation_policy import ConversationPolicy from app.services.orchestration.entity_normalizer import EntityNormalizer from app.services.orchestration.response_formatter import fallback_format_tool_result -from app.services.tools.tool_registry import ToolRegistry +from app.services.tools.tool_registry import GeneratedToolCoreBoundaryViolation, ToolRegistry from app.services.tools.handlers import _parse_data_hora_revisao @@ -3648,5 +3648,59 @@ class ToolRegistryExecutionTests(unittest.IsolatedAsyncioTestCase): ) + def test_register_generated_tool_accepts_isolated_runtime_package(self): + async def run(**kwargs): + return kwargs + + run.__module__ = "generated_tools.emitir_resumo_locacao" + + registry = ToolRegistry.__new__(ToolRegistry) + registry._tools = [] + + registry.register_generated_tool( + name="emitir_resumo_locacao", + description="", + parameters={}, + handler=run, + ) + + self.assertEqual(len(registry.get_tools()), 1) + self.assertEqual(registry.get_tools()[0].name, "emitir_resumo_locacao") + + def test_register_generated_tool_blocks_core_name_collision(self): + async def run(**kwargs): + return kwargs + + run.__module__ = "generated_tools.consultar_estoque" + + registry = ToolRegistry.__new__(ToolRegistry) + registry._tools = [] + + with self.assertRaisesRegex(GeneratedToolCoreBoundaryViolation, "catalogo core"): + registry.register_generated_tool( + name="consultar_estoque", + description="", + parameters={}, + handler=run, + ) + + def test_register_generated_tool_blocks_core_module_target(self): + async def run(**kwargs): + return kwargs + + run.__module__ = "app.services.tools.handlers" + + registry = ToolRegistry.__new__(ToolRegistry) + registry._tools = [] + + with self.assertRaisesRegex(GeneratedToolCoreBoundaryViolation, "generated_tools"): + registry.register_generated_tool( + name="emitir_resumo_locacao", + description="", + parameters={}, + handler=run, + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_runtime_bootstrap.py b/tests/test_runtime_bootstrap.py index d96d38d..30630de 100644 --- a/tests/test_runtime_bootstrap.py +++ b/tests/test_runtime_bootstrap.py @@ -1,4 +1,6 @@ import unittest +from pathlib import Path +from tempfile import TemporaryDirectory from unittest.mock import AsyncMock, patch from app import main as main_module @@ -28,6 +30,37 @@ class SettingsParsingTests(unittest.TestCase): class BootstrapRuntimeTests(unittest.TestCase): + @patch.object(bootstrap_module, "seed_tools") + @patch.object(bootstrap_module, "seed_mock_data") + @patch.object(bootstrap_module, "_ensure_mock_schema_evolution") + @patch.object(bootstrap_module.MockBase.metadata, "create_all") + @patch.object(bootstrap_module.Base.metadata, "create_all") + @patch.object(bootstrap_module, "_ensure_generated_tools_runtime_package") + def test_bootstrap_databases_ensures_generated_tools_package( + self, + ensure_generated_tools_runtime_package, + tools_create_all, + mock_create_all, + ensure_mock_schema_evolution, + seed_mock_data, + seed_tools, + ): + ensure_generated_tools_runtime_package.return_value = Path("generated_tools") + + bootstrap_module.bootstrap_databases() + + ensure_generated_tools_runtime_package.assert_called_once_with() + tools_create_all.assert_called_once() + mock_create_all.assert_called_once() + + def test_ensure_generated_tools_runtime_package_creates_package_files(self): + with TemporaryDirectory() as temp_dir: + with patch.object(bootstrap_module, "_PROJECT_ROOT", Path(temp_dir)): + package_dir = bootstrap_module._ensure_generated_tools_runtime_package() + self.assertEqual(package_dir.name, "generated_tools") + self.assertTrue(package_dir.exists()) + self.assertTrue((package_dir / "__init__.py").exists()) + @patch.object(bootstrap_module, "seed_tools") @patch.object(bootstrap_module, "seed_mock_data") @patch.object(bootstrap_module, "_ensure_mock_schema_evolution") diff --git a/tests/test_shared_contracts.py b/tests/test_shared_contracts.py index 7a8364a..1ee7b01 100644 --- a/tests/test_shared_contracts.py +++ b/tests/test_shared_contracts.py @@ -4,6 +4,8 @@ from datetime import datetime, timezone from shared.contracts import ( AdminPermission, BOT_GOVERNED_SETTINGS, + GENERATED_TOOL_ENTRYPOINT, + GENERATED_TOOLS_PACKAGE, MODEL_RUNTIME_PROFILES, MODEL_RUNTIME_SEPARATION_RULES, BotGovernanceArea, @@ -35,6 +37,8 @@ from shared.contracts import ( ToolParameterContract, ToolParameterType, ToolPublicationEnvelope, + build_generated_tool_module_name, + build_generated_tool_module_path, get_bot_governed_setting, get_tool_lifecycle_stage, get_functional_configuration, @@ -121,6 +125,18 @@ class ToolPublicationContractTests(unittest.TestCase): ToolParameterType.NUMBER, ) + def test_generated_tool_namespace_uses_isolated_package(self): + self.assertEqual(GENERATED_TOOLS_PACKAGE, "generated_tools") + self.assertEqual(GENERATED_TOOL_ENTRYPOINT, "run") + self.assertEqual( + build_generated_tool_module_name("consultar_financiamento"), + "generated_tools.consultar_financiamento", + ) + self.assertEqual( + build_generated_tool_module_path("consultar_financiamento"), + "generated_tools/consultar_financiamento.py", + ) + def test_lifecycle_catalog_exposes_expected_states_in_order(self): self.assertEqual( TOOL_LIFECYCLE_STATUS_SEQUENCE,