diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index fa70c6e..84205e1 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, @@ -17,7 +18,7 @@ from admin_app.api.schemas import ( ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.services import ToolManagementService -from shared.contracts import AdminPermission +from shared.contracts import AdminPermission, StaffRole, role_has_permission router = APIRouter(prefix="/panel/tools", tags=["panel-tools"]) @@ -29,7 +30,7 @@ router = APIRouter(prefix="/panel/tools", tags=["panel-tools"]) def panel_tools_overview( settings: AdminSettings = Depends(get_settings), service: ToolManagementService = Depends(get_tool_management_service), - _: AuthenticatedStaffPrincipal = Depends( + current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) ), ): @@ -39,7 +40,7 @@ def panel_tools_overview( mode=payload["mode"], metrics=payload["metrics"], workflow=payload["workflow"], - actions=_build_panel_actions(settings), + actions=_build_panel_actions(settings, current_staff.role), next_steps=payload["next_steps"], ) @@ -102,6 +103,7 @@ def panel_tool_draft_intake( draft.model_dump(), owner_staff_account_id=current_staff.id, owner_name=current_staff.display_name, + owner_role=current_staff.role, ) except ValueError as exc: raise HTTPException( @@ -113,6 +115,7 @@ def panel_tool_draft_intake( service="orquestrador-admin", storage_status=payload["storage_status"], message=payload["message"], + submission_policy=payload["submission_policy"], draft_preview=payload["draft_preview"], warnings=payload["warnings"], next_steps=payload["next_steps"], @@ -139,6 +142,62 @@ def panel_tool_review_queue( ) +@router.post( + "/review-queue/{version_id}/review", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_review( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.review_version( + version_id, + reviewer_staff_account_id=current_staff.id, + reviewer_name=current_staff.display_name, + reviewer_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/approve", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_approve( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.approve_version( + version_id, + approver_staff_account_id=current_staff.id, + approver_name=current_staff.display_name, + approver_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/publications", response_model=AdminToolPublicationListResponse, @@ -158,9 +217,54 @@ def panel_tool_publications( ) +@router.post( + "/publications/{version_id}/publish", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_publish( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.publish_version( + version_id, + publisher_staff_account_id=current_staff.id, + publisher_name=current_staff.display_name, + publisher_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + -def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]: - return [ + +def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: + return AdminToolGovernanceTransitionResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + queue_entry=payload["queue_entry"], + publication=payload["publication"], + next_steps=payload["next_steps"], + ) + + +def _build_panel_actions( + settings: AdminSettings, + current_role: StaffRole | str | None = None, +) -> list[AdminToolManagementActionResponse]: + actions = [ AdminToolManagementActionResponse( key="overview", label="Overview web de tools", @@ -197,7 +301,9 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct description="Catalogo de tools ativas e prontas para ativacao no produto.", ), ] - + if current_role is None: + return actions + return [action for action in actions if role_has_permission(current_role, action.required_permission)] def _build_prefixed_path(api_prefix: str, path: str) -> str: diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index 40ef92f..f543f12 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, @@ -17,7 +18,7 @@ from admin_app.api.schemas import ( ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.services import ToolManagementService -from shared.contracts import AdminPermission +from shared.contracts import AdminPermission, StaffRole, role_has_permission router = APIRouter(prefix="/tools", tags=["tools"]) @@ -29,7 +30,7 @@ router = APIRouter(prefix="/tools", tags=["tools"]) def tools_overview( settings: AdminSettings = Depends(get_settings), service: ToolManagementService = Depends(get_tool_management_service), - _: AuthenticatedStaffPrincipal = Depends( + current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) ), ): @@ -39,7 +40,7 @@ def tools_overview( mode=payload["mode"], metrics=payload["metrics"], workflow=payload["workflow"], - actions=_build_actions(settings), + actions=_build_actions(settings, current_staff.role), next_steps=payload["next_steps"], ) @@ -102,6 +103,7 @@ def tool_draft_intake( draft.model_dump(), owner_staff_account_id=current_staff.id, owner_name=current_staff.display_name, + owner_role=current_staff.role, ) except ValueError as exc: raise HTTPException( @@ -113,6 +115,7 @@ def tool_draft_intake( service="orquestrador-admin", storage_status=payload["storage_status"], message=payload["message"], + submission_policy=payload["submission_policy"], draft_preview=payload["draft_preview"], warnings=payload["warnings"], next_steps=payload["next_steps"], @@ -139,6 +142,62 @@ def tool_review_queue( ) +@router.post( + "/review-queue/{version_id}/review", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_review( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.review_version( + version_id, + reviewer_staff_account_id=current_staff.id, + reviewer_name=current_staff.display_name, + reviewer_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/approve", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_approve( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.approve_version( + version_id, + approver_staff_account_id=current_staff.id, + approver_name=current_staff.display_name, + approver_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/publications", response_model=AdminToolPublicationListResponse, @@ -158,8 +217,54 @@ def tool_publications( ) -def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]: - return [ +@router.post( + "/publications/{version_id}/publish", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_publish( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.publish_version( + version_id, + publisher_staff_account_id=current_staff.id, + publisher_name=current_staff.display_name, + publisher_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + + +def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: + return AdminToolGovernanceTransitionResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + queue_entry=payload["queue_entry"], + publication=payload["publication"], + next_steps=payload["next_steps"], + ) + + +def _build_actions( + settings: AdminSettings, + current_role: StaffRole | str | None = None, +) -> list[AdminToolManagementActionResponse]: + actions = [ AdminToolManagementActionResponse( key="overview", label="Overview de tools", @@ -203,7 +308,9 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes description="Catalogo bootstrap de tools ativas voltadas ao runtime de produto.", ), ] - + if current_role is None: + return actions + return [action for action in actions if role_has_permission(current_role, action.required_permission)] def _build_prefixed_path(api_prefix: str, path: str) -> str: diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 0bbdf3c..fd27baa 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -755,6 +755,8 @@ class AdminToolDraftListResponse(BaseModel): class AdminToolReviewQueueEntryResponse(BaseModel): entry_id: str + version_id: str + version_number: int = Field(ge=1) tool_name: str display_name: str status: ToolLifecycleStatus @@ -802,6 +804,19 @@ class AdminToolPublicationListResponse(BaseModel): target_service: ServiceName publications: list[AdminToolPublicationSummaryResponse] + +class AdminToolGovernanceTransitionResponse(BaseModel): + service: str + message: str + version_id: str + tool_name: str + version_number: int = Field(ge=1) + status: ToolLifecycleStatus + queue_entry: AdminToolReviewQueueEntryResponse | None = None + publication: AdminToolPublicationSummaryResponse | None = None + next_steps: list[str] + + class AdminToolDraftIntakeParameterRequest(BaseModel): name: str = Field(min_length=1, max_length=64) parameter_type: ToolParameterType @@ -843,6 +858,17 @@ class AdminToolDraftIntakeRequest(BaseModel): return value.strip().lower() +class AdminToolDraftSubmissionPolicyResponse(BaseModel): + mode: str + submitter_role: StaffRole | None = None + submitter_can_publish_now: bool + direct_publication_blocked: bool + requires_director_approval: bool + required_approver_role: StaffRole + required_review_permission: AdminPermission + required_publish_permission: AdminPermission + + class AdminToolDraftIntakePreviewParameterResponse(BaseModel): name: str parameter_type: ToolParameterType @@ -872,6 +898,7 @@ class AdminToolDraftIntakeResponse(BaseModel): service: str storage_status: str message: str + submission_policy: AdminToolDraftSubmissionPolicyResponse draft_preview: AdminToolDraftIntakePreviewResponse warnings: list[str] next_steps: list[str] diff --git a/admin_app/catalogs/__init__.py b/admin_app/catalogs/__init__.py new file mode 100644 index 0000000..277cdee --- /dev/null +++ b/admin_app/catalogs/__init__.py @@ -0,0 +1,13 @@ +from admin_app.catalogs.tool_governance_catalog import ( + BOOTSTRAP_TOOL_CATALOG, + INTAKE_DOMAIN_OPTIONS, + BootstrapToolCatalogEntry, + ToolIntakeDomainOption, +) + +__all__ = [ + "BOOTSTRAP_TOOL_CATALOG", + "INTAKE_DOMAIN_OPTIONS", + "BootstrapToolCatalogEntry", + "ToolIntakeDomainOption", +] \ No newline at end of file diff --git a/admin_app/catalogs/tool_governance_catalog.py b/admin_app/catalogs/tool_governance_catalog.py new file mode 100644 index 0000000..1cdefbe --- /dev/null +++ b/admin_app/catalogs/tool_governance_catalog.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BootstrapToolCatalogEntry: + tool_name: str + display_name: str + description: str + domain: str + parameter_count: int + + +@dataclass(frozen=True) +class ToolIntakeDomainOption: + value: str + label: str + description: str + + +BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( + BootstrapToolCatalogEntry( + tool_name="consultar_estoque", + display_name="Consultar estoque", + description="Consulta veiculos disponiveis no estoque comercial.", + domain="vendas", + parameter_count=4, + ), + BootstrapToolCatalogEntry( + tool_name="validar_cliente_venda", + display_name="Validar cliente para venda", + description="Avalia elegibilidade de credito para operacoes de venda.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="avaliar_veiculo_troca", + display_name="Avaliar veiculo de troca", + description="Estima o valor de entrada de um veiculo usado.", + domain="vendas", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="agendar_revisao", + display_name="Agendar revisao", + description="Abre um agendamento de revisao ou manutencao.", + domain="revisao", + parameter_count=6, + ), + BootstrapToolCatalogEntry( + tool_name="listar_agendamentos_revisao", + display_name="Listar agendamentos de revisao", + description="Consulta a fila de agendamentos de revisao do cliente.", + domain="revisao", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_agendamento_revisao", + display_name="Cancelar agendamento de revisao", + description="Cancela um agendamento existente por protocolo.", + domain="revisao", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="editar_data_revisao", + display_name="Editar data de revisao", + description="Remarca uma revisao para um novo horario.", + domain="revisao", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="realizar_pedido", + display_name="Realizar pedido", + description="Efetiva um pedido de compra com o veiculo escolhido.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="listar_pedidos", + display_name="Listar pedidos", + description="Consulta pedidos ja abertos pelo cliente.", + domain="vendas", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_pedido", + display_name="Cancelar pedido", + description="Cancela um pedido existente com motivo registrado.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="consultar_frota_aluguel", + display_name="Consultar frota de aluguel", + description="Lista veiculos disponiveis para locacao.", + domain="locacao", + parameter_count=6, + ), + BootstrapToolCatalogEntry( + tool_name="abrir_locacao_aluguel", + display_name="Abrir locacao de aluguel", + description="Inicia um contrato de locacao de veiculo.", + domain="locacao", + parameter_count=7, + ), + BootstrapToolCatalogEntry( + tool_name="registrar_devolucao_aluguel", + display_name="Registrar devolucao de aluguel", + description="Fecha uma locacao e devolve o veiculo para a frota.", + domain="locacao", + parameter_count=4, + ), + BootstrapToolCatalogEntry( + tool_name="registrar_pagamento_aluguel", + display_name="Registrar pagamento de aluguel", + description="Registra comprovantes e pagamentos de contratos de locacao.", + domain="locacao", + parameter_count=7, + ), + BootstrapToolCatalogEntry( + tool_name="limpar_contexto_conversa", + display_name="Limpar contexto de conversa", + description="Reinicia o contexto operacional atual do atendimento.", + domain="orquestracao", + parameter_count=1, + ), + BootstrapToolCatalogEntry( + tool_name="continuar_proximo_pedido", + display_name="Continuar proximo pedido", + description="Retoma o proximo pedido pendente do fluxo atual.", + domain="orquestracao", + parameter_count=0, + ), + BootstrapToolCatalogEntry( + tool_name="descartar_pedidos_pendentes", + display_name="Descartar pedidos pendentes", + description="Descarta apenas a fila pendente de pedidos do contexto.", + domain="orquestracao", + parameter_count=1, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_fluxo_atual", + display_name="Cancelar fluxo atual", + description="Interrompe o fluxo corrente sem apagar todo o contexto.", + domain="orquestracao", + parameter_count=1, + ), +) + +INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = ( + ToolIntakeDomainOption( + value="vendas", + label="Vendas", + description="Ferramentas para estoque, negociacao, pedido e conversao comercial.", + ), + ToolIntakeDomainOption( + value="revisao", + label="Revisao", + description="Ferramentas para agendamento, remarcacao e operacao da oficina.", + ), + ToolIntakeDomainOption( + value="locacao", + label="Locacao", + description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.", + ), + ToolIntakeDomainOption( + value="orquestracao", + label="Orquestracao", + description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.", + ), +) \ No newline at end of file diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py index 79be380..0d8c1b0 100644 --- a/admin_app/db/models/tool_artifact.py +++ b/admin_app/db/models/tool_artifact.py @@ -12,11 +12,15 @@ from admin_app.db.models.base import AdminTimestampedModel class ToolArtifactStage(str, Enum): GENERATION = "generation" VALIDATION = "validation" + GOVERNANCE = "governance" class ToolArtifactKind(str, Enum): GENERATION_REQUEST = "generation_request" VALIDATION_REPORT = "validation_report" + DIRECTOR_REVIEW = "director_review" + DIRECTOR_APPROVAL = "director_approval" + PUBLICATION_RELEASE = "publication_release" class ToolArtifactStorageKind(str, Enum): @@ -111,3 +115,4 @@ class ToolArtifact(AdminTimestampedModel): index=True, ) author_display_name: Mapped[str] = mapped_column(String(150), nullable=False) + diff --git a/admin_app/repositories/tool_draft_repository.py b/admin_app/repositories/tool_draft_repository.py index 40d9277..cf0b130 100644 --- a/admin_app/repositories/tool_draft_repository.py +++ b/admin_app/repositories/tool_draft_repository.py @@ -108,6 +108,22 @@ class ToolDraftRepository(BaseRepository): self.db.flush() return draft + def update_status( + self, + draft: ToolDraft, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolDraft: + draft.status = status + if commit: + self.db.commit() + self.db.refresh(draft) + else: + self.db.flush() + return draft + @staticmethod def _build_draft_id() -> str: return f"draft_{uuid4().hex[:24]}" + diff --git a/admin_app/repositories/tool_metadata_repository.py b/admin_app/repositories/tool_metadata_repository.py index 15b59d0..7c33b6f 100644 --- a/admin_app/repositories/tool_metadata_repository.py +++ b/admin_app/repositories/tool_metadata_repository.py @@ -94,6 +94,21 @@ class ToolMetadataRepository(BaseRepository): self.db.flush() return metadata + def update_status( + self, + metadata: ToolMetadata, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolMetadata: + metadata.status = status + if commit: + self.db.commit() + self.db.refresh(metadata) + else: + self.db.flush() + return metadata + def upsert_version_metadata( self, *, @@ -142,3 +157,4 @@ class ToolMetadataRepository(BaseRepository): 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/repositories/tool_version_repository.py b/admin_app/repositories/tool_version_repository.py index 5e59575..923c8ca 100644 --- a/admin_app/repositories/tool_version_repository.py +++ b/admin_app/repositories/tool_version_repository.py @@ -35,6 +35,12 @@ class ToolVersionRepository(BaseRepository): max_version = self.db.execute(statement).scalar_one_or_none() return int(max_version or 0) + 1 + def get_by_version_id(self, version_id: str) -> ToolVersion | None: + statement = select(ToolVersion).where( + ToolVersion.version_id == str(version_id or "").strip().lower() + ) + return self.db.execute(statement).scalar_one_or_none() + def create( self, *, @@ -75,7 +81,23 @@ class ToolVersionRepository(BaseRepository): self.db.flush() return version + def update_status( + self, + version: ToolVersion, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolVersion: + version.status = status + if commit: + self.db.commit() + self.db.refresh(version) + else: + self.db.flush() + return version + @staticmethod def build_version_id(tool_name: str, version_number: int) -> str: normalized_tool_name = str(tool_name or "").strip().lower() return f"tool_version::{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 1f32313..da181b9 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -1,9 +1,11 @@ from __future__ import annotations import re -from dataclasses import dataclass from datetime import UTC, datetime +from sqlalchemy.orm import Session + +from admin_app.catalogs import BOOTSTRAP_TOOL_CATALOG, INTAKE_DOMAIN_OPTIONS from admin_app.core.settings import AdminSettings from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion from admin_app.db.models.tool_artifact import ( @@ -16,183 +18,18 @@ 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 ( + AdminPermission, GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE, ServiceName, + StaffRole, TOOL_LIFECYCLE_STAGES, ToolLifecycleStatus, ToolParameterType, build_generated_tool_module_name, build_generated_tool_module_path, -) - - -@dataclass(frozen=True) -class BootstrapToolCatalogEntry: - tool_name: str - display_name: str - description: str - domain: str - parameter_count: int - - -@dataclass(frozen=True) -class ToolIntakeDomainOption: - value: str - label: str - description: str - - -_BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( - BootstrapToolCatalogEntry( - tool_name="consultar_estoque", - display_name="Consultar estoque", - description="Consulta veiculos disponiveis no estoque comercial.", - domain="vendas", - parameter_count=4, - ), - BootstrapToolCatalogEntry( - tool_name="validar_cliente_venda", - display_name="Validar cliente para venda", - description="Avalia elegibilidade de credito para operacoes de venda.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="avaliar_veiculo_troca", - display_name="Avaliar veiculo de troca", - description="Estima o valor de entrada de um veiculo usado.", - domain="vendas", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="agendar_revisao", - display_name="Agendar revisao", - description="Abre um agendamento de revisao ou manutencao.", - domain="revisao", - parameter_count=6, - ), - BootstrapToolCatalogEntry( - tool_name="listar_agendamentos_revisao", - display_name="Listar agendamentos de revisao", - description="Consulta a fila de agendamentos de revisao do cliente.", - domain="revisao", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_agendamento_revisao", - display_name="Cancelar agendamento de revisao", - description="Cancela um agendamento existente por protocolo.", - domain="revisao", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="editar_data_revisao", - display_name="Editar data de revisao", - description="Remarca uma revisao para um novo horario.", - domain="revisao", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="realizar_pedido", - display_name="Realizar pedido", - description="Efetiva um pedido de compra com o veiculo escolhido.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="listar_pedidos", - display_name="Listar pedidos", - description="Consulta pedidos ja abertos pelo cliente.", - domain="vendas", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_pedido", - display_name="Cancelar pedido", - description="Cancela um pedido existente com motivo registrado.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="consultar_frota_aluguel", - display_name="Consultar frota de aluguel", - description="Lista veiculos disponiveis para locacao.", - domain="locacao", - parameter_count=6, - ), - BootstrapToolCatalogEntry( - tool_name="abrir_locacao_aluguel", - display_name="Abrir locacao de aluguel", - description="Inicia um contrato de locacao de veiculo.", - domain="locacao", - parameter_count=7, - ), - BootstrapToolCatalogEntry( - tool_name="registrar_devolucao_aluguel", - display_name="Registrar devolucao de aluguel", - description="Fecha uma locacao e devolve o veiculo para a frota.", - domain="locacao", - parameter_count=4, - ), - BootstrapToolCatalogEntry( - tool_name="registrar_pagamento_aluguel", - display_name="Registrar pagamento de aluguel", - description="Registra comprovantes e pagamentos de contratos de locacao.", - domain="locacao", - parameter_count=7, - ), - BootstrapToolCatalogEntry( - tool_name="limpar_contexto_conversa", - display_name="Limpar contexto de conversa", - description="Reinicia o contexto operacional atual do atendimento.", - domain="orquestracao", - parameter_count=1, - ), - BootstrapToolCatalogEntry( - tool_name="continuar_proximo_pedido", - display_name="Continuar proximo pedido", - description="Retoma o proximo pedido pendente do fluxo atual.", - domain="orquestracao", - parameter_count=0, - ), - BootstrapToolCatalogEntry( - tool_name="descartar_pedidos_pendentes", - display_name="Descartar pedidos pendentes", - description="Descarta apenas a fila pendente de pedidos do contexto.", - domain="orquestracao", - parameter_count=1, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_fluxo_atual", - display_name="Cancelar fluxo atual", - description="Interrompe o fluxo corrente sem apagar todo o contexto.", - domain="orquestracao", - parameter_count=1, - ), -) - -_INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = ( - ToolIntakeDomainOption( - value="vendas", - label="Vendas", - description="Ferramentas para estoque, negociacao, pedido e conversao comercial.", - ), - ToolIntakeDomainOption( - value="revisao", - label="Revisao", - description="Ferramentas para agendamento, remarcacao e operacao da oficina.", - ), - ToolIntakeDomainOption( - value="locacao", - label="Locacao", - description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.", - ), - ToolIntakeDomainOption( - value="orquestracao", - label="Orquestracao", - description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.", - ), + normalize_staff_role, + role_has_permission, ) @@ -207,7 +44,15 @@ _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) +_RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in BOOTSTRAP_TOOL_CATALOG) +_PUBLISHED_TOOL_STATUSES = (ToolLifecycleStatus.ACTIVE,) +_REVIEW_QUEUE_STATUSES = ( + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.VALIDATED, + ToolLifecycleStatus.APPROVED, + ToolLifecycleStatus.FAILED, +) class ToolManagementService: @@ -225,6 +70,61 @@ class ToolManagementService: self.metadata_repository = metadata_repository self.artifact_repository = artifact_repository + def _resolve_repository_session(self) -> Session | None: + repository_sessions = [ + repository.db + for repository in ( + self.draft_repository, + self.version_repository, + self.metadata_repository, + self.artifact_repository, + ) + if getattr(repository, "db", None) is not None + ] + + if not repository_sessions: + return None + + primary_session = repository_sessions[0] + for repository_session in repository_sessions[1:]: + if repository_session is not primary_session: + raise RuntimeError("Tool governance repositories must share the same admin database session.") + return primary_session + + @staticmethod + def _commit_repository_session( + repository_session: Session, + *, + draft: ToolDraft, + version: ToolVersion | None = None, + ) -> None: + repository_session.commit() + repository_session.refresh(draft) + if version is not None: + repository_session.refresh(version) + + def _build_submission_policy( + self, + *, + submitter_role: StaffRole | str | None = None, + ) -> dict: + normalized_role = normalize_staff_role(submitter_role) if submitter_role is not None else None + submitter_can_publish_now = ( + role_has_permission(normalized_role, AdminPermission.PUBLISH_TOOLS) + if normalized_role is not None + else False + ) + return { + "mode": "draft_only", + "submitter_role": normalized_role, + "submitter_can_publish_now": submitter_can_publish_now, + "direct_publication_blocked": True, + "requires_director_approval": True, + "required_approver_role": StaffRole.DIRETOR, + "required_review_permission": AdminPermission.REVIEW_TOOL_GENERATIONS, + "required_publish_permission": AdminPermission.PUBLISH_TOOLS, + } + def build_overview_payload(self) -> dict: catalog_payload = self.build_publications_payload() catalog = catalog_payload["publications"] @@ -325,16 +225,27 @@ class ToolManagementService: ], } - def build_draft_form_payload(self) -> dict: + def build_draft_form_payload( + self, + *, + submitter_role: StaffRole | str | None = None, + ) -> dict: + submission_policy = self._build_submission_policy(submitter_role=submitter_role) + submitter_note = ( + "Sua sessao pode cadastrar e salvar o draft, mas nao publica a tool diretamente." + if not submission_policy["submitter_can_publish_now"] + else "Mesmo com permissao de publicacao, este formulario sempre salva a tool primeiro como draft versionado." + ) return { "mode": "validated_preview", + "submission_policy": submission_policy, "domain_options": [ { "value": option.value, "label": option.label, "description": option.description, } - for option in _INTAKE_DOMAIN_OPTIONS + for option in INTAKE_DOMAIN_OPTIONS ], "parameter_types": [ { @@ -352,6 +263,7 @@ class ToolManagementService: ], "submission_notes": [ "O colaborador pode preencher, validar e persistir o draft da tool no painel.", + submitter_note, "Toda tool nova segue para revisao e aprovacao de um diretor antes de qualquer publicacao.", "Reenvios da mesma tool reaproveitam o draft raiz e geram uma nova versao administrativa.", ], @@ -387,36 +299,241 @@ class ToolManagementService: } def build_review_queue_payload(self) -> dict: + queued_versions = self._list_latest_versions(statuses=_REVIEW_QUEUE_STATUSES) + message = ( + "Nenhuma versao aguardando revisao, aprovacao ou publicacao de diretor." + if not queued_versions + else f"{len(queued_versions)} versao(oes) aguardando atuacao de diretor antes da ativacao." + ) return { - "queue_mode": "bootstrap_empty_state", - "message": ( - "A fila de revisao ainda opera em estado vazio ate a criacao das entidades de geracao e validacao conectadas as versoes persistidas de cada draft." - ), - "items": [], - "supported_statuses": [ - ToolLifecycleStatus.GENERATED, - ToolLifecycleStatus.VALIDATED, - ToolLifecycleStatus.APPROVED, - ToolLifecycleStatus.FAILED, - ], + "queue_mode": "governed_admin_queue", + "message": message, + "items": [self._serialize_review_queue_entry(version) for version in queued_versions], + "supported_statuses": list(_REVIEW_QUEUE_STATUSES), } def build_publications_payload(self) -> dict: - metadata_entries = self._list_latest_metadata_entries() - if metadata_entries: + publications_by_tool_name = { + publication["tool_name"]: publication + for publication in self.list_publication_catalog() + } + published_metadata_entries = self._list_latest_metadata_entries( + statuses=_PUBLISHED_TOOL_STATUSES, + ) + if published_metadata_entries: + for metadata in published_metadata_entries: + publications_by_tool_name[metadata.tool_name] = self._serialize_metadata_publication( + metadata + ) return { - "source": "admin_metadata_catalog", + "source": "hybrid_runtime_catalog", "target_service": ServiceName.PRODUCT, - "publications": [ - self._serialize_metadata_publication(metadata) - for metadata in metadata_entries - ], + "publications": list(publications_by_tool_name.values()), } return { "source": "bootstrap_catalog", "target_service": ServiceName.PRODUCT, - "publications": self.list_publication_catalog(), + "publications": list(publications_by_tool_name.values()), + } + + def review_version( + self, + version_id: str, + *, + reviewer_staff_account_id: int, + reviewer_name: str, + reviewer_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.VALIDATED, + allowed_current_statuses=( + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ), + actor_staff_account_id=reviewer_staff_account_id, + actor_name=reviewer_name, + actor_role=reviewer_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.DIRECTOR_REVIEW, + artifact_summary="Revisao inicial de diretor registrada para a versao governada.", + success_message="Versao revisada por diretor com sucesso e pronta para aprovacao.", + next_steps=[ + "A diretoria ainda precisa aprovar formalmente a versao antes da publicacao.", + "Depois da aprovacao, a publicacao ativa a tool no catalogo governado do produto.", + ], + ) + + def approve_version( + self, + version_id: str, + *, + approver_staff_account_id: int, + approver_name: str, + approver_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.APPROVED, + allowed_current_statuses=(ToolLifecycleStatus.VALIDATED,), + actor_staff_account_id=approver_staff_account_id, + actor_name=approver_name, + actor_role=approver_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.DIRECTOR_APPROVAL, + artifact_summary="Aprovacao de diretor registrada para a versao governada.", + success_message="Versao aprovada por diretor com sucesso e pronta para publicacao.", + next_steps=[ + "A publicacao administrativa ainda precisa ser executada antes da ativacao.", + "Enquanto a versao estiver apenas aprovada, ela permanece fora do catalogo ativo do produto.", + ], + ) + + def publish_version( + self, + version_id: str, + *, + publisher_staff_account_id: int, + publisher_name: str, + publisher_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.ACTIVE, + allowed_current_statuses=(ToolLifecycleStatus.APPROVED,), + actor_staff_account_id=publisher_staff_account_id, + actor_name=publisher_name, + actor_role=publisher_role, + required_permission=AdminPermission.PUBLISH_TOOLS, + artifact_kind=ToolArtifactKind.PUBLICATION_RELEASE, + artifact_summary="Publicacao administrativa concluida pela diretoria antes da ativacao.", + success_message="Versao publicada com sucesso e ativada no catalogo governado.", + next_steps=[ + "A versao ativa agora pode ser consumida pelo runtime governado do produto.", + "Se uma nova versao for publicada para a mesma tool, a ativa anterior sera arquivada automaticamente.", + ], + ) + + def _transition_version_status( + self, + version_id: str, + *, + target_status: ToolLifecycleStatus, + allowed_current_statuses: tuple[ToolLifecycleStatus, ...], + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + required_permission: AdminPermission, + artifact_kind: ToolArtifactKind, + artifact_summary: str, + success_message: str, + next_steps: list[str], + ) -> dict: + normalized_role = normalize_staff_role(actor_role) + if not role_has_permission(normalized_role, required_permission): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{required_permission.value}'." + ) + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Fluxo de governanca de tools ainda nao esta completamente conectado ao armazenamento administrativo." + ) + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + raise LookupError("Versao administrativa nao encontrada.") + + latest_versions_for_tool = self.version_repository.list_versions(tool_name=version.tool_name) + if latest_versions_for_tool and latest_versions_for_tool[0].version_id != version.version_id: + raise ValueError( + "Somente a versao mais recente da tool pode seguir para revisao, aprovacao e publicacao." + ) + if version.status not in allowed_current_statuses: + expected_statuses = ", ".join(status.value for status in allowed_current_statuses) + raise ValueError( + f"A transicao solicitada exige status em ({expected_statuses}), mas a versao esta em '{version.status.value}'." + ) + + draft = self.draft_repository.get_by_tool_name(version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para a versao governada.") + metadata = self.metadata_repository.get_by_tool_version_id(version.id) + if metadata is None: + raise RuntimeError("Metadados persistidos da versao nao encontrados para a governanca administrativa.") + + previous_status = version.status + repository_session = self._resolve_repository_session() + atomic_write_options = {"commit": False} if repository_session is not None else {} + artifact_commit = False if repository_session is not None else None + + try: + if target_status == ToolLifecycleStatus.ACTIVE: + self._archive_active_publications( + tool_name=version.tool_name, + excluding_version_id=version.id, + **atomic_write_options, + ) + + self.version_repository.update_status( + version, + status=target_status, + **atomic_write_options, + ) + self.metadata_repository.update_status( + metadata, + status=target_status, + **atomic_write_options, + ) + self.draft_repository.update_status( + draft, + status=target_status, + **atomic_write_options, + ) + self._persist_governance_artifact( + draft=draft, + version=version, + artifact_kind=artifact_kind, + summary=artifact_summary, + previous_status=previous_status, + current_status=target_status, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + commit=artifact_commit, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + queue_entry = None + publication = None + if target_status == ToolLifecycleStatus.ACTIVE: + publication = self._serialize_metadata_publication(metadata) + else: + queue_entry = self._serialize_review_queue_entry(version) + + return { + "message": success_message, + "version_id": version.version_id, + "tool_name": version.tool_name, + "version_number": version.version_number, + "status": target_status, + "queue_entry": queue_entry, + "publication": publication, + "next_steps": next_steps, } def create_draft_submission( @@ -425,12 +542,14 @@ class ToolManagementService: *, owner_staff_account_id: int | None = None, owner_name: str | None = None, + owner_role: StaffRole | str | None = None, ) -> dict: normalized = self._normalize_draft_payload(payload) warnings = self._build_intake_warnings(normalized) required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"]) summary = self._build_draft_summary(normalized) stored_parameters = self._serialize_parameters_for_storage(normalized["parameters"]) + submission_policy = self._build_submission_policy(submitter_role=owner_role) if self.draft_repository is None: version_number = 1 @@ -438,7 +557,8 @@ class ToolManagementService: version_id = self._build_preview_version_id(normalized["tool_name"], version_number) return { "storage_status": "validated_preview", - "message": "Pre-cadastro validado no painel. A persistencia definitiva entra na fase de governanca de tools.", + "message": "Pre-cadastro validado no painel sem publicacao direta. A persistencia definitiva entra na fase de governanca de tools.", + "submission_policy": submission_policy, "draft_preview": { "draft_id": f"preview::{normalized['tool_name']}", "version_id": version_id, @@ -467,90 +587,113 @@ class ToolManagementService: if owner_staff_account_id is None: raise ValueError("owner_staff_account_id e obrigatorio para persistir o draft.") + repository_session = self._resolve_repository_session() + atomic_write_options = {"commit": False} if repository_session is not None else {} + artifact_commit = False if repository_session is not None else None + owner_display_name = owner_name or "Autor administrativo" + existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) next_version_number = self._resolve_next_version_number(normalized["tool_name"], existing_draft) next_version_count = next_version_number if existing_draft is None else max(existing_draft.version_count + 1, next_version_number) - if existing_draft is None: - draft = self.draft_repository.create( - tool_name=normalized["tool_name"], - display_name=normalized["display_name"], - domain=normalized["domain"], - description=normalized["description"], - business_goal=normalized["business_goal"], - summary=summary, - parameters_json=stored_parameters, - required_parameter_count=required_parameter_count, - current_version_number=next_version_number, - version_count=next_version_count, - owner_staff_account_id=owner_staff_account_id, - owner_display_name=owner_name or "Autor administrativo", - requires_director_approval=True, - ) - else: - draft = self.draft_repository.update_submission( - existing_draft, - display_name=normalized["display_name"], - domain=normalized["domain"], - description=normalized["description"], - business_goal=normalized["business_goal"], - summary=summary, - parameters_json=stored_parameters, - required_parameter_count=required_parameter_count, - current_version_number=next_version_number, - version_count=next_version_count, - owner_staff_account_id=owner_staff_account_id, - owner_display_name=owner_name or "Autor administrativo", - requires_director_approval=True, - ) + try: + if existing_draft is None: + draft = self.draft_repository.create( + tool_name=normalized["tool_name"], + display_name=normalized["display_name"], + domain=normalized["domain"], + description=normalized["description"], + business_goal=normalized["business_goal"], + summary=summary, + parameters_json=stored_parameters, + required_parameter_count=required_parameter_count, + current_version_number=next_version_number, + version_count=next_version_count, + owner_staff_account_id=owner_staff_account_id, + owner_display_name=owner_display_name, + requires_director_approval=True, + **atomic_write_options, + ) + else: + draft = self.draft_repository.update_submission( + existing_draft, + display_name=normalized["display_name"], + domain=normalized["domain"], + description=normalized["description"], + business_goal=normalized["business_goal"], + summary=summary, + parameters_json=stored_parameters, + required_parameter_count=required_parameter_count, + current_version_number=next_version_number, + version_count=next_version_count, + owner_staff_account_id=owner_staff_account_id, + owner_display_name=owner_display_name, + requires_director_approval=True, + **atomic_write_options, + ) - version = None - if self.version_repository is not None: - version = self.version_repository.create( - draft_id=draft.id, - tool_name=draft.tool_name, - version_number=next_version_number, - summary=summary, - description=normalized["description"], - business_goal=normalized["business_goal"], - parameters_json=stored_parameters, - required_parameter_count=required_parameter_count, - owner_staff_account_id=owner_staff_account_id, - owner_display_name=owner_name or "Autor administrativo", - status=ToolLifecycleStatus.DRAFT, - requires_director_approval=True, - ) + version = None + if self.version_repository is not None: + version = self.version_repository.create( + draft_id=draft.id, + tool_name=draft.tool_name, + version_number=next_version_number, + summary=summary, + description=normalized["description"], + business_goal=normalized["business_goal"], + parameters_json=stored_parameters, + required_parameter_count=required_parameter_count, + owner_staff_account_id=owner_staff_account_id, + owner_display_name=owner_display_name, + status=ToolLifecycleStatus.DRAFT, + requires_director_approval=True, + **atomic_write_options, + ) - 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.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, + **atomic_write_options, + ) - 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", - ) + 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_display_name, + commit=artifact_commit, + ) + + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise return { "storage_status": "admin_database", - "message": "Draft administrativo persistido com sucesso em fluxo versionado.", + "message": "Draft administrativo persistido com sucesso sem publicacao direta, em fluxo versionado e governado.", + "submission_policy": submission_policy, "draft_preview": self._serialize_draft_preview(draft, version), "warnings": warnings, "next_steps": [ @@ -560,11 +703,18 @@ class ToolManagementService: ], } - def preview_draft_submission(self, payload: dict, *, owner_name: str | None = None) -> dict: + def preview_draft_submission( + self, + payload: dict, + *, + owner_name: str | None = None, + owner_role: StaffRole | str | None = None, + ) -> dict: normalized = self._normalize_draft_payload(payload) warnings = self._build_intake_warnings(normalized) required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"]) summary = self._build_draft_summary(normalized) + submission_policy = self._build_submission_policy(submitter_role=owner_role) existing_draft = None if self.draft_repository is not None: existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) @@ -572,7 +722,8 @@ class ToolManagementService: version_count = version_number if existing_draft is None else max(existing_draft.version_count + 1, version_number) return { "storage_status": "validated_preview", - "message": "Pre-cadastro validado no painel com numeracao de versao reservada para a tool.", + "message": "Pre-cadastro validado no painel com numeracao de versao reservada para a tool, sem publicacao direta nesta etapa.", + "submission_policy": submission_policy, "draft_preview": { "draft_id": existing_draft.draft_id if existing_draft is not None else f"preview::{normalized['tool_name']}", "version_id": self._build_preview_version_id(normalized["tool_name"], version_number), @@ -627,9 +778,106 @@ class ToolManagementService: "published_by": "bootstrap_catalog", "published_at": published_at, } - for entry in _BOOTSTRAP_TOOL_CATALOG + for entry in BOOTSTRAP_TOOL_CATALOG ] + def _archive_active_publications( + self, + *, + tool_name: str, + excluding_version_id: int, + commit: bool = True, + ) -> None: + if self.version_repository is not None: + for active_version in self.version_repository.list_versions( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ACTIVE,), + ): + if active_version.id == excluding_version_id: + continue + self.version_repository.update_status( + active_version, + status=ToolLifecycleStatus.ARCHIVED, + commit=commit, + ) + if self.metadata_repository is not None: + for active_metadata in self.metadata_repository.list_metadata( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ACTIVE,), + ): + if active_metadata.tool_version_id == excluding_version_id: + continue + self.metadata_repository.update_status( + active_metadata, + status=ToolLifecycleStatus.ARCHIVED, + commit=commit, + ) + + def _persist_governance_artifact( + self, + *, + draft: ToolDraft, + version: ToolVersion, + artifact_kind: ToolArtifactKind, + summary: str, + previous_status: ToolLifecycleStatus, + current_status: ToolLifecycleStatus, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole, + commit: bool | None = None, + ) -> None: + if self.artifact_repository is None: + return + + artifact_write_options = {"commit": commit} if commit is not None else {} + self.artifact_repository.upsert_version_artifact( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=version.tool_name, + version_number=version.version_number, + artifact_stage=ToolArtifactStage.GOVERNANCE, + artifact_kind=artifact_kind, + artifact_status=ToolArtifactStatus.SUCCEEDED, + summary=summary, + payload_json=self._build_governance_artifact_payload( + version=version, + artifact_kind=artifact_kind, + previous_status=previous_status, + current_status=current_status, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + ), + author_staff_account_id=actor_staff_account_id, + author_display_name=actor_name, + **artifact_write_options, + ) + + @staticmethod + def _build_governance_artifact_payload( + *, + version: ToolVersion, + artifact_kind: ToolArtifactKind, + previous_status: ToolLifecycleStatus, + current_status: ToolLifecycleStatus, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole, + ) -> dict: + return { + "source": "director_governance", + "action": artifact_kind.value, + "tool_name": version.tool_name, + "version_id": version.version_id, + "version_number": version.version_number, + "previous_status": previous_status.value, + "current_status": current_status.value, + "actor_staff_account_id": actor_staff_account_id, + "actor_display_name": actor_name, + "actor_role": actor_role.value, + } + def _persist_initial_version_artifacts( self, *, @@ -641,10 +889,13 @@ class ToolManagementService: required_parameter_count: int, owner_staff_account_id: int, owner_name: str, + commit: bool | None = None, ) -> None: if self.artifact_repository is None: return + artifact_write_options = {"commit": commit} if commit is not None else {} + generation_payload = self._build_generation_artifact_payload( draft=draft, version=version, @@ -671,6 +922,7 @@ class ToolManagementService: payload_json=generation_payload, author_staff_account_id=owner_staff_account_id, author_display_name=owner_name, + **artifact_write_options, ) self.artifact_repository.upsert_version_artifact( draft_id=draft.id, @@ -684,6 +936,7 @@ class ToolManagementService: payload_json=validation_payload, author_staff_account_id=owner_staff_account_id, author_display_name=owner_name, + **artifact_write_options, ) @staticmethod @@ -743,12 +996,66 @@ class ToolManagementService: ], } - def _list_latest_metadata_entries(self) -> list[ToolMetadata]: + def _list_latest_versions( + self, + *, + statuses: tuple[ToolLifecycleStatus, ...] | None = None, + ) -> list[ToolVersion]: + if self.version_repository is None: + return [] + + latest_by_tool_name: dict[str, ToolVersion | None] = {} + for version in self.version_repository.list_versions(): + normalized_tool_name = str(version.tool_name or "").strip().lower() + if normalized_tool_name in latest_by_tool_name: + continue + if statuses is not None and version.status not in statuses: + latest_by_tool_name[normalized_tool_name] = None + continue + latest_by_tool_name[normalized_tool_name] = version + return [version for version in latest_by_tool_name.values() if version is not None] + + def _serialize_review_queue_entry(self, version: ToolVersion) -> dict: + metadata = ( + self.metadata_repository.get_by_tool_version_id(version.id) + if self.metadata_repository is not None + else None + ) + display_name = metadata.display_name if metadata is not None else version.tool_name.replace("_", " ").title() + return { + "entry_id": version.version_id, + "version_id": version.version_id, + "version_number": version.version_number, + "tool_name": version.tool_name, + "display_name": display_name, + "status": version.status, + "gate": self._build_review_gate(version.status), + "summary": version.summary, + "owner_name": version.owner_display_name, + "queued_at": version.updated_at or version.created_at, + } + + @staticmethod + def _build_review_gate(status: ToolLifecycleStatus) -> str: + gate_by_status = { + ToolLifecycleStatus.DRAFT: "director_review_required", + ToolLifecycleStatus.GENERATED: "validation_confirmation_required", + ToolLifecycleStatus.VALIDATED: "director_approval_required", + ToolLifecycleStatus.APPROVED: "director_publication_required", + ToolLifecycleStatus.FAILED: "revision_required", + } + return gate_by_status.get(status, "governance_required") + + def _list_latest_metadata_entries( + self, + *, + statuses: tuple[ToolLifecycleStatus, ...] | None = None, + ) -> list[ToolMetadata]: if self.metadata_repository is None: return [] latest_by_tool_name: dict[str, ToolMetadata] = {} - for metadata in self.metadata_repository.list_metadata(): + for metadata in self.metadata_repository.list_metadata(statuses=statuses): normalized_tool_name = str(metadata.tool_name or "").strip().lower() if normalized_tool_name in latest_by_tool_name: continue @@ -878,7 +1185,7 @@ class ToolManagementService: raise ValueError("display_name precisa ter pelo menos 4 caracteres.") domain = str(payload.get("domain") or "").strip().lower() - valid_domains = {option.value for option in _INTAKE_DOMAIN_OPTIONS} + valid_domains = {option.value for option in INTAKE_DOMAIN_OPTIONS} if domain not in valid_domains: raise ValueError("Selecione um dominio valido para a nova tool.") diff --git a/admin_app/view/router.py b/admin_app/view/router.py index a24efb8..b376f16 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -89,7 +89,7 @@ def tool_intake_page( return _redirect_to_route(request, "admin_login_view") settings = _resolve_settings(request) - view = _build_tool_intake_view(request, settings) + view = _build_tool_intake_view(request, settings, current_context.principal.role) css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css")) js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js")) return HTMLResponse(render_tool_intake_page(view, css_href=css_href, js_href=js_href)) @@ -661,9 +661,13 @@ def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPa ) -def _build_tool_intake_view(request: Request, settings: AdminSettings) -> AdminToolIntakePageView: +def _build_tool_intake_view( + request: Request, + settings: AdminSettings, + current_role: StaffRole | str | None, +) -> AdminToolIntakePageView: service = ToolManagementService(settings) - form_payload = service.build_draft_form_payload() + form_payload = service.build_draft_form_payload(submitter_role=current_role) return AdminToolIntakePageView( app_name=settings.admin_app_name, diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index ea6f25e..2484102 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -368,6 +368,7 @@ function mountToolIntakePage(page) { function renderDraftPreview(payload) { const draft = payload?.draft_preview; + const submissionPolicy = payload?.submission_policy || null; const warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; const parameters = Array.isArray(draft?.parameters) ? draft.parameters : []; @@ -392,6 +393,9 @@ function mountToolIntakePage(page) {
Obrigatorios: ${escapeHtml(String(draft?.required_parameter_count || 0))}
Aprovacao: ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}
+ ${submissionPolicy + ? `
Governanca desta submissao
Modo: ${escapeHtml(submissionPolicy.mode || "draft_only")}. Papel atual: ${escapeHtml(submissionPolicy.submitter_role || "nao informado")}. Publicacao direta: ${submissionPolicy.direct_publication_blocked ? "bloqueada neste fluxo" : "permitida"}. Permissao final de publicacao: ${escapeHtml(submissionPolicy.required_publish_permission || "publish_tools")}.<\/div><\/div>` + : ""}
${parameters.length > 0 ? parameters.map((item) => `
${escapeHtml(item.name)}
${escapeHtml(item.description)}
${escapeHtml(item.parameter_type)}
`).join("") diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index 24a993e..4840dbb 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -73,6 +73,11 @@ class _FakeToolDraftRepository: draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, tzinfo=timezone.utc) return draft + def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft: + draft.status = status + draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, 30, tzinfo=timezone.utc) + return draft + class _FakeToolVersionRepository: def __init__(self): @@ -102,6 +107,13 @@ class _FakeToolVersionRepository: versions = self.list_versions(tool_name=tool_name) return (versions[0].version_number if versions else 0) + 1 + def get_by_version_id(self, version_id: str) -> ToolVersion | None: + normalized = str(version_id or "").strip().lower() + for version in self.versions: + if version.version_id == normalized: + return version + return None + def create(self, **kwargs) -> ToolVersion: version_number = kwargs["version_number"] now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc) @@ -116,6 +128,11 @@ class _FakeToolVersionRepository: self.versions.append(version) return version + def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion: + version.status = status + version.updated_at = datetime(2026, 3, 31, 18, version.version_number, 30, tzinfo=timezone.utc) + return version + @staticmethod def build_version_id(tool_name: str, version_number: int) -> str: normalized = str(tool_name or "").strip().lower() @@ -175,6 +192,11 @@ class _FakeToolMetadataRepository: metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, tzinfo=timezone.utc) return metadata + def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata: + metadata.status = status + metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, 30, 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: @@ -312,6 +334,8 @@ class AdminPanelToolsWebTests(unittest.TestCase): 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"]]) + self.assertNotIn("/admin/panel/tools/review-queue", [item["href"] for item in payload["actions"]]) + self.assertNotIn("/admin/panel/tools/publications", [item["href"] for item in payload["actions"]]) 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) @@ -370,6 +394,12 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["storage_status"], "admin_database") + self.assertEqual(payload["submission_policy"]["mode"], "draft_only") + self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador") + self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"]) + self.assertTrue(payload["submission_policy"]["direct_publication_blocked"]) + self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor") + self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools") self.assertEqual(payload["draft_preview"]["status"], "draft") self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo") self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1") @@ -438,15 +468,58 @@ class AdminPanelToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'review_tool_generations'.", ) + def test_panel_tools_review_action_requires_director_session(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'review_tool_generations'.", + ) + def test_panel_tools_review_queue_is_available_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": [], + }, + ) response = client.get("/admin/panel/tools/review-queue") finally: app.dependency_overrides.clear() + self.assertEqual(intake_response.status_code, 200) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state") + payload = response.json() + self.assertEqual(payload["queue_mode"], "governed_admin_queue") + self.assertEqual(len(payload["items"]), 1) + self.assertEqual(payload["items"][0]["status"], "draft") + self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertEqual(payload["items"][0]["version_number"], 1) def test_panel_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) @@ -461,6 +534,32 @@ class AdminPanelToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'publish_tools'.", ) + def test_panel_tools_publish_action_requires_director_publication_permission(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post(f"/admin/panel/tools/publications/{version_id}/publish") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'publish_tools'.", + ) + def test_panel_tools_publications_return_catalog_for_diretor_session(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: @@ -475,7 +574,7 @@ class AdminPanelToolsWebTests(unittest.TestCase): 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): + def test_panel_tools_publications_keep_bootstrap_catalog_after_intake(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: intake_response = client.post( @@ -503,12 +602,64 @@ class AdminPanelToolsWebTests(unittest.TestCase): 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(payload["source"], "bootstrap_catalog") + self.assertGreaterEqual(len(payload["publications"]), 10) + self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in payload["publications"]]) + + def test_panel_tools_director_workflow_reviews_approves_and_publishes_before_activation(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, + } + ], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + publish_before_approval = client.post(f"/admin/panel/tools/publications/{version_id}/publish") + review_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") + approve_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/approve") + pre_publications = client.get("/admin/panel/tools/publications") + publish_response = client.post(f"/admin/panel/tools/publications/{version_id}/publish") + final_publications = client.get("/admin/panel/tools/publications") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(publish_before_approval.status_code, 409) + self.assertIn("approved", publish_before_approval.json()["detail"]) + self.assertEqual(review_response.status_code, 200) + self.assertEqual(review_response.json()["status"], "validated") + self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required") + self.assertEqual(approve_response.status_code, 200) + self.assertEqual(approve_response.json()["status"], "approved") + self.assertEqual(approve_response.json()["queue_entry"]["gate"], "director_publication_required") + self.assertEqual(pre_publications.status_code, 200) + self.assertEqual(pre_publications.json()["source"], "bootstrap_catalog") + self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in pre_publications.json()["publications"]]) + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(publish_response.json()["status"], "active") + self.assertIsNone(publish_response.json()["queue_entry"]) + self.assertEqual(publish_response.json()["publication"]["tool_name"], "emitir_resumo_locacao") + self.assertEqual(final_publications.status_code, 200) + payload = final_publications.json() + self.assertEqual(payload["source"], "hybrid_runtime_catalog") + self.assertGreaterEqual(len(payload["publications"]), 11) + publication = next(item for item in payload["publications"] if item["tool_name"] == "emitir_resumo_locacao") 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["status"], "active") 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) diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py index 4b968f0..f3f1a2c 100644 --- a/tests/test_admin_tool_management_service.py +++ b/tests/test_admin_tool_management_service.py @@ -1,13 +1,23 @@ import unittest from datetime import datetime, timezone +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + from admin_app.core import AdminSettings -from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion +from admin_app.db.database import AdminBase +from admin_app.db.models import StaffAccount, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion from admin_app.db.models.tool_artifact import ToolArtifactKind +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 admin_app.services.tool_management_service import ToolManagementService from shared.contracts import ( + AdminPermission, GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE, + StaffRole, ToolLifecycleStatus, ToolParameterType, build_generated_tool_module_name, @@ -115,6 +125,11 @@ class _FakeToolDraftRepository: draft.updated_at = datetime(2026, 3, 31, 15, current_version_number, tzinfo=timezone.utc) return draft + def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft: + draft.status = status + draft.updated_at = datetime(2026, 3, 31, 15, draft.current_version_number, 30, tzinfo=timezone.utc) + return draft + class _FakeToolVersionRepository: def __init__(self): @@ -144,6 +159,13 @@ class _FakeToolVersionRepository: versions = self.list_versions(tool_name=tool_name) return (versions[0].version_number if versions else 0) + 1 + def get_by_version_id(self, version_id: str) -> ToolVersion | None: + normalized = str(version_id or "").strip().lower() + for version in self.versions: + if version.version_id == normalized: + return version + return None + def create( self, *, @@ -184,6 +206,11 @@ class _FakeToolVersionRepository: self.versions.append(version) return version + def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion: + version.status = status + version.updated_at = datetime(2026, 3, 31, 16, version.version_number, 30, tzinfo=timezone.utc) + return version + @staticmethod def build_version_id(tool_name: str, version_number: int) -> str: normalized = str(tool_name or "").strip().lower() @@ -243,6 +270,11 @@ class _FakeToolMetadataRepository: metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, tzinfo=timezone.utc) return metadata + def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata: + metadata.status = status + metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, 30, 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: @@ -324,6 +356,19 @@ class _FakeToolArtifactRepository: return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}" +class _FailingToolArtifactRepository(ToolArtifactRepository): + def __init__(self, db, *, fail_on_call: int): + super().__init__(db) + self.fail_on_call = fail_on_call + self.calls = 0 + + def upsert_version_artifact(self, **kwargs): + self.calls += 1 + if self.calls == self.fail_on_call: + raise RuntimeError("artifact persistence failure") + return super().upsert_version_artifact(**kwargs) + + class AdminToolManagementServiceTests(unittest.TestCase): def setUp(self): self.draft_repository = _FakeToolDraftRepository() @@ -363,9 +408,16 @@ class AdminToolManagementServiceTests(unittest.TestCase): }, owner_staff_account_id=7, owner_name="Equipe Interna", + owner_role=StaffRole.COLABORADOR, ) self.assertEqual(payload["storage_status"], "admin_database") + self.assertEqual(payload["submission_policy"]["mode"], "draft_only") + self.assertEqual(payload["submission_policy"]["submitter_role"], StaffRole.COLABORADOR) + self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"]) + self.assertTrue(payload["submission_policy"]["direct_publication_blocked"]) + self.assertEqual(payload["submission_policy"]["required_approver_role"], StaffRole.DIRETOR) + self.assertEqual(payload["submission_policy"]["required_publish_permission"], AdminPermission.PUBLISH_TOOLS) self.assertEqual(payload["draft_preview"]["draft_id"], "draft_fake_1") self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1") self.assertEqual(payload["draft_preview"]["version_number"], 1) @@ -506,7 +558,7 @@ 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): + def test_build_publications_payload_keeps_bootstrap_catalog_for_draft_metadata(self): self.service.create_draft_submission( { "domain": "revisao", @@ -529,20 +581,265 @@ class AdminToolManagementServiceTests(unittest.TestCase): payload = self.service.build_publications_payload() - self.assertEqual(payload["source"], "admin_metadata_catalog") + self.assertEqual(payload["source"], "bootstrap_catalog") self.assertEqual(payload["target_service"], "product") - self.assertEqual(len(payload["publications"]), 1) - publication = payload["publications"][0] + self.assertGreaterEqual(len(payload["publications"]), 10) + self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in payload["publications"]]) + + def test_build_publications_payload_merges_active_governed_publications_with_bootstrap_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", + ) + self.metadata_repository.metadata_entries[0].status = ToolLifecycleStatus.ACTIVE + + payload = self.service.build_publications_payload() + + self.assertEqual(payload["source"], "hybrid_runtime_catalog") + self.assertEqual(payload["target_service"], "product") + self.assertGreaterEqual(len(payload["publications"]), 11) + self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]]) + publication = next( + item for item in payload["publications"] if item["tool_name"] == "consultar_revisao_aberta" + ) 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["status"], ToolLifecycleStatus.ACTIVE) 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) + def test_build_review_queue_payload_returns_latest_version_pending_director_action(self): + intake_payload = 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_review_queue_payload() + + self.assertEqual(payload["queue_mode"], "governed_admin_queue") + self.assertEqual(len(payload["items"]), 1) + self.assertEqual(payload["items"][0]["version_id"], intake_payload["draft_preview"]["version_id"]) + self.assertEqual(payload["items"][0]["version_number"], 1) + self.assertEqual(payload["items"][0]["status"], ToolLifecycleStatus.DRAFT) + self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertIn(ToolLifecycleStatus.APPROVED, payload["supported_statuses"]) + + def test_director_must_review_approve_and_publish_before_activation(self): + intake_payload = self.service.create_draft_submission( + { + "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, + } + ], + }, + owner_staff_account_id=3, + owner_name="Equipe Interna", + ) + version_id = intake_payload["draft_preview"]["version_id"] + + review_payload = self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + ) + approve_payload = self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + ) + publish_payload = self.service.publish_version( + version_id, + publisher_staff_account_id=99, + publisher_name="Diretoria", + publisher_role=StaffRole.DIRETOR, + ) + + self.assertEqual(review_payload["status"], ToolLifecycleStatus.VALIDATED) + self.assertEqual(review_payload["queue_entry"]["gate"], "director_approval_required") + self.assertEqual(approve_payload["status"], ToolLifecycleStatus.APPROVED) + self.assertEqual(approve_payload["queue_entry"]["gate"], "director_publication_required") + self.assertEqual(publish_payload["status"], ToolLifecycleStatus.ACTIVE) + self.assertIsNone(publish_payload["queue_entry"]) + self.assertEqual(publish_payload["publication"]["tool_name"], "emitir_resumo_locacao") + self.assertEqual(self.draft_repository.drafts[0].status, ToolLifecycleStatus.ACTIVE) + self.assertEqual(self.version_repository.versions[0].status, ToolLifecycleStatus.ACTIVE) + self.assertEqual(self.metadata_repository.metadata_entries[0].status, ToolLifecycleStatus.ACTIVE) + artifact_kinds = {artifact.artifact_kind for artifact in self.artifact_repository.artifacts} + self.assertIn(ToolArtifactKind.DIRECTOR_REVIEW, artifact_kinds) + self.assertIn(ToolArtifactKind.DIRECTOR_APPROVAL, artifact_kinds) + self.assertIn(ToolArtifactKind.PUBLICATION_RELEASE, artifact_kinds) + + def test_publish_requires_prior_director_review_and_approval(self): + intake_payload = 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": [], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + ) + + with self.assertRaisesRegex(ValueError, "approved"): + self.service.publish_version( + intake_payload["draft_preview"]["version_id"], + publisher_staff_account_id=99, + publisher_name="Diretoria", + publisher_role=StaffRole.DIRETOR, + ) + + def test_publishing_new_version_archives_previous_active_version(self): + first_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado para acompanhamento administrativo.", + "business_goal": "Dar visibilidade ao time interno sobre os principais gargalos do funil.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + first_version_id = first_intake["draft_preview"]["version_id"] + self.service.review_version(first_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) + self.service.approve_version(first_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) + self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + second_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado com campos adicionais para acompanhamento administrativo.", + "business_goal": "Dar visibilidade ao time interno sobre gargalos, volume e conversao do funil.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + second_version_id = second_intake["draft_preview"]["version_id"] + self.service.review_version(second_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) + self.service.approve_version(second_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) + self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + versions_by_number = {version.version_number: version for version in self.version_repository.versions} + metadata_by_number = {metadata.version_number: metadata for metadata in self.metadata_repository.metadata_entries} + self.assertEqual(versions_by_number[1].status, ToolLifecycleStatus.ARCHIVED) + self.assertEqual(metadata_by_number[1].status, ToolLifecycleStatus.ARCHIVED) + self.assertEqual(versions_by_number[2].status, ToolLifecycleStatus.ACTIVE) + self.assertEqual(metadata_by_number[2].status, ToolLifecycleStatus.ACTIVE) + + +class AdminToolManagementTransactionalPersistenceTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + AdminBase.metadata.create_all(bind=self.engine) + session_local = sessionmaker(autocommit=False, autoflush=False, bind=self.engine) + self.db = session_local() + + owner = StaffAccount( + email="tools-admin@example.com", + display_name="Equipe Interna", + password_hash="hash", + role=StaffRole.COLABORADOR, + is_active=True, + ) + self.db.add(owner) + self.db.commit() + self.db.refresh(owner) + self.owner = owner + + self.draft_repository = ToolDraftRepository(self.db) + self.version_repository = ToolVersionRepository(self.db) + self.metadata_repository = ToolMetadataRepository(self.db) + self.artifact_repository = _FailingToolArtifactRepository(self.db, fail_on_call=2) + 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 tearDown(self): + self.db.close() + self.engine.dispose() + + def test_create_draft_submission_rolls_back_all_rows_when_artifact_persistence_fails(self): + with self.assertRaisesRegex(RuntimeError, "artifact persistence failure"): + self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consolidar_funil_interno", + "display_name": "Consolidar funil interno", + "description": "Consolida indicadores internos do funil comercial para acompanhamento administrativo.", + "business_goal": "Permitir que o time acompanhe a saude do funil sem depender de consultas manuais repetidas.", + "parameters": [ + { + "name": "periodo_inicio", + "parameter_type": "string", + "description": "Data inicial usada na consolidacao.", + "required": True, + } + ], + }, + owner_staff_account_id=self.owner.id, + owner_name=self.owner.display_name, + ) + + self.assertEqual(self.draft_repository.list_drafts(), []) + self.assertEqual(self.version_repository.list_versions(), []) + self.assertEqual(self.metadata_repository.list_metadata(), []) + self.assertEqual(self.artifact_repository.list_artifacts(), []) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index a2f63fd..f61e553 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -70,6 +70,11 @@ class _FakeToolDraftRepository: draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, tzinfo=timezone.utc) return draft + def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft: + draft.status = status + draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, 30, tzinfo=timezone.utc) + return draft + class _FakeToolVersionRepository: def __init__(self): @@ -99,6 +104,13 @@ class _FakeToolVersionRepository: versions = self.list_versions(tool_name=tool_name) return (versions[0].version_number if versions else 0) + 1 + def get_by_version_id(self, version_id: str) -> ToolVersion | None: + normalized = str(version_id or "").strip().lower() + for version in self.versions: + if version.version_id == normalized: + return version + return None + def create(self, **kwargs) -> ToolVersion: version_number = kwargs["version_number"] now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc) @@ -113,6 +125,11 @@ class _FakeToolVersionRepository: self.versions.append(version) return version + def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion: + version.status = status + version.updated_at = datetime(2026, 3, 31, 17, version.version_number, 30, tzinfo=timezone.utc) + return version + @staticmethod def build_version_id(tool_name: str, version_number: int) -> str: normalized = str(tool_name or "").strip().lower() @@ -172,6 +189,11 @@ class _FakeToolMetadataRepository: metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, tzinfo=timezone.utc) return metadata + def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata: + metadata.status = status + metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, 30, 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: @@ -312,6 +334,8 @@ class AdminToolsWebTests(unittest.TestCase): 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.assertNotIn("/admin/tools/review-queue", [item["href"] for item in payload["actions"]]) + self.assertNotIn("/admin/tools/publications", [item["href"] for item in payload["actions"]]) self.assertIn("artefatos", payload["next_steps"][0].lower()) def test_tools_contracts_return_shared_contract_snapshot(self): @@ -427,6 +451,12 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["storage_status"], "admin_database") + self.assertEqual(payload["submission_policy"]["mode"], "draft_only") + self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador") + self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"]) + self.assertTrue(payload["submission_policy"]["direct_publication_blocked"]) + self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor") + self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools") self.assertEqual(payload["draft_preview"]["status"], "draft") self.assertEqual(payload["draft_preview"]["domain"], "orquestracao") self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::priorizar_contato_quente::v1") @@ -455,18 +485,64 @@ class AdminToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'review_tool_generations'.", ) + def test_tools_review_action_requires_director_review_permission(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post( + f"/admin/tools/review-queue/{version_id}/review", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'review_tool_generations'.", + ) + def test_tools_review_queue_is_available_for_diretor(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": [], + }, + ) response = client.get("/admin/tools/review-queue", 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["queue_mode"], "bootstrap_empty_state") - self.assertEqual(payload["items"], []) - self.assertIn("validated", payload["supported_statuses"]) + self.assertEqual(payload["queue_mode"], "governed_admin_queue") + self.assertEqual(len(payload["items"]), 1) + self.assertEqual(payload["items"][0]["status"], "draft") + self.assertEqual(payload["items"][0]["gate"], "director_review_required") + self.assertEqual(payload["items"][0]["version_number"], 1) + self.assertIn("approved", payload["supported_statuses"]) def test_tools_publications_require_director_publication_permission(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) @@ -481,6 +557,36 @@ class AdminToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'publish_tools'.", ) + def test_tools_publish_action_requires_director_publication_permission(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) + 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": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + response = client.post( + f"/admin/tools/publications/{version_id}/publish", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'publish_tools'.", + ) + def test_tools_publications_return_bootstrap_catalog_for_diretor(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: @@ -498,7 +604,7 @@ 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): + def test_tools_publications_keep_bootstrap_catalog_after_intake(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) try: intake_response = client.post( @@ -527,12 +633,77 @@ class AdminToolsWebTests(unittest.TestCase): 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(payload["source"], "bootstrap_catalog") + self.assertGreaterEqual(len(payload["publications"]), 10) + self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in payload["publications"]]) + + def test_tools_director_workflow_reviews_approves_and_publishes_before_activation(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, + } + ], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + publish_before_approval = client.post( + f"/admin/tools/publications/{version_id}/publish", + headers={"Authorization": "Bearer token"}, + ) + review_response = client.post( + f"/admin/tools/review-queue/{version_id}/review", + headers={"Authorization": "Bearer token"}, + ) + approve_response = client.post( + f"/admin/tools/review-queue/{version_id}/approve", + headers={"Authorization": "Bearer token"}, + ) + pre_publications = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) + publish_response = client.post( + f"/admin/tools/publications/{version_id}/publish", + headers={"Authorization": "Bearer token"}, + ) + final_publications = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(publish_before_approval.status_code, 409) + self.assertIn("approved", publish_before_approval.json()["detail"]) + self.assertEqual(review_response.status_code, 200) + self.assertEqual(review_response.json()["status"], "validated") + self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required") + self.assertEqual(approve_response.status_code, 200) + self.assertEqual(approve_response.json()["status"], "approved") + self.assertEqual(approve_response.json()["queue_entry"]["gate"], "director_publication_required") + self.assertEqual(pre_publications.status_code, 200) + self.assertEqual(pre_publications.json()["source"], "bootstrap_catalog") + self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in pre_publications.json()["publications"]]) + self.assertEqual(publish_response.status_code, 200) + self.assertEqual(publish_response.json()["status"], "active") + self.assertIsNone(publish_response.json()["queue_entry"]) + self.assertEqual(publish_response.json()["publication"]["tool_name"], "consultar_revisao_aberta") + self.assertEqual(final_publications.status_code, 200) + payload = final_publications.json() + self.assertEqual(payload["source"], "hybrid_runtime_catalog") + self.assertGreaterEqual(len(payload["publications"]), 11) + publication = next(item for item in payload["publications"] if item["tool_name"] == "consultar_revisao_aberta") 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["status"], "active") 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"))