feat(admin): concluir fluxo governado de tools na fase 5

feat/self-evolving-tools-foundation
parent 3dcf80eaaa
commit 2e3a695878

@ -10,6 +10,7 @@ from admin_app.api.schemas import (
AdminToolDraftIntakeRequest, AdminToolDraftIntakeRequest,
AdminToolDraftIntakeResponse, AdminToolDraftIntakeResponse,
AdminToolDraftListResponse, AdminToolDraftListResponse,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse, AdminToolManagementActionResponse,
AdminToolOverviewResponse, AdminToolOverviewResponse,
AdminToolPublicationListResponse, AdminToolPublicationListResponse,
@ -17,7 +18,7 @@ from admin_app.api.schemas import (
) )
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService 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"]) router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
@ -29,7 +30,7 @@ router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
def panel_tools_overview( def panel_tools_overview(
settings: AdminSettings = Depends(get_settings), settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service), service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends( current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
), ),
): ):
@ -39,7 +40,7 @@ def panel_tools_overview(
mode=payload["mode"], mode=payload["mode"],
metrics=payload["metrics"], metrics=payload["metrics"],
workflow=payload["workflow"], workflow=payload["workflow"],
actions=_build_panel_actions(settings), actions=_build_panel_actions(settings, current_staff.role),
next_steps=payload["next_steps"], next_steps=payload["next_steps"],
) )
@ -102,6 +103,7 @@ def panel_tool_draft_intake(
draft.model_dump(), draft.model_dump(),
owner_staff_account_id=current_staff.id, owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name, owner_name=current_staff.display_name,
owner_role=current_staff.role,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
@ -113,6 +115,7 @@ def panel_tool_draft_intake(
service="orquestrador-admin", service="orquestrador-admin",
storage_status=payload["storage_status"], storage_status=payload["storage_status"],
message=payload["message"], message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"], draft_preview=payload["draft_preview"],
warnings=payload["warnings"], warnings=payload["warnings"],
next_steps=payload["next_steps"], 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( @router.get(
"/publications", "/publications",
response_model=AdminToolPublicationListResponse, 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( AdminToolManagementActionResponse(
key="overview", key="overview",
label="Overview web de tools", 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.", 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: def _build_prefixed_path(api_prefix: str, path: str) -> str:

@ -10,6 +10,7 @@ from admin_app.api.schemas import (
AdminToolDraftIntakeRequest, AdminToolDraftIntakeRequest,
AdminToolDraftIntakeResponse, AdminToolDraftIntakeResponse,
AdminToolDraftListResponse, AdminToolDraftListResponse,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse, AdminToolManagementActionResponse,
AdminToolOverviewResponse, AdminToolOverviewResponse,
AdminToolPublicationListResponse, AdminToolPublicationListResponse,
@ -17,7 +18,7 @@ from admin_app.api.schemas import (
) )
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService 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"]) router = APIRouter(prefix="/tools", tags=["tools"])
@ -29,7 +30,7 @@ router = APIRouter(prefix="/tools", tags=["tools"])
def tools_overview( def tools_overview(
settings: AdminSettings = Depends(get_settings), settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service), service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends( current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
), ),
): ):
@ -39,7 +40,7 @@ def tools_overview(
mode=payload["mode"], mode=payload["mode"],
metrics=payload["metrics"], metrics=payload["metrics"],
workflow=payload["workflow"], workflow=payload["workflow"],
actions=_build_actions(settings), actions=_build_actions(settings, current_staff.role),
next_steps=payload["next_steps"], next_steps=payload["next_steps"],
) )
@ -102,6 +103,7 @@ def tool_draft_intake(
draft.model_dump(), draft.model_dump(),
owner_staff_account_id=current_staff.id, owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name, owner_name=current_staff.display_name,
owner_role=current_staff.role,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException( raise HTTPException(
@ -113,6 +115,7 @@ def tool_draft_intake(
service="orquestrador-admin", service="orquestrador-admin",
storage_status=payload["storage_status"], storage_status=payload["storage_status"],
message=payload["message"], message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"], draft_preview=payload["draft_preview"],
warnings=payload["warnings"], warnings=payload["warnings"],
next_steps=payload["next_steps"], 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( @router.get(
"/publications", "/publications",
response_model=AdminToolPublicationListResponse, response_model=AdminToolPublicationListResponse,
@ -158,8 +217,54 @@ def tool_publications(
) )
def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]: @router.post(
return [ "/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( AdminToolManagementActionResponse(
key="overview", key="overview",
label="Overview de tools", 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.", 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: def _build_prefixed_path(api_prefix: str, path: str) -> str:

@ -755,6 +755,8 @@ class AdminToolDraftListResponse(BaseModel):
class AdminToolReviewQueueEntryResponse(BaseModel): class AdminToolReviewQueueEntryResponse(BaseModel):
entry_id: str entry_id: str
version_id: str
version_number: int = Field(ge=1)
tool_name: str tool_name: str
display_name: str display_name: str
status: ToolLifecycleStatus status: ToolLifecycleStatus
@ -802,6 +804,19 @@ class AdminToolPublicationListResponse(BaseModel):
target_service: ServiceName target_service: ServiceName
publications: list[AdminToolPublicationSummaryResponse] 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): class AdminToolDraftIntakeParameterRequest(BaseModel):
name: str = Field(min_length=1, max_length=64) name: str = Field(min_length=1, max_length=64)
parameter_type: ToolParameterType parameter_type: ToolParameterType
@ -843,6 +858,17 @@ class AdminToolDraftIntakeRequest(BaseModel):
return value.strip().lower() 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): class AdminToolDraftIntakePreviewParameterResponse(BaseModel):
name: str name: str
parameter_type: ToolParameterType parameter_type: ToolParameterType
@ -872,6 +898,7 @@ class AdminToolDraftIntakeResponse(BaseModel):
service: str service: str
storage_status: str storage_status: str
message: str message: str
submission_policy: AdminToolDraftSubmissionPolicyResponse
draft_preview: AdminToolDraftIntakePreviewResponse draft_preview: AdminToolDraftIntakePreviewResponse
warnings: list[str] warnings: list[str]
next_steps: list[str] next_steps: list[str]

@ -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",
]

@ -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.",
),
)

@ -12,11 +12,15 @@ from admin_app.db.models.base import AdminTimestampedModel
class ToolArtifactStage(str, Enum): class ToolArtifactStage(str, Enum):
GENERATION = "generation" GENERATION = "generation"
VALIDATION = "validation" VALIDATION = "validation"
GOVERNANCE = "governance"
class ToolArtifactKind(str, Enum): class ToolArtifactKind(str, Enum):
GENERATION_REQUEST = "generation_request" GENERATION_REQUEST = "generation_request"
VALIDATION_REPORT = "validation_report" VALIDATION_REPORT = "validation_report"
DIRECTOR_REVIEW = "director_review"
DIRECTOR_APPROVAL = "director_approval"
PUBLICATION_RELEASE = "publication_release"
class ToolArtifactStorageKind(str, Enum): class ToolArtifactStorageKind(str, Enum):
@ -111,3 +115,4 @@ class ToolArtifact(AdminTimestampedModel):
index=True, index=True,
) )
author_display_name: Mapped[str] = mapped_column(String(150), nullable=False) author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)

@ -108,6 +108,22 @@ class ToolDraftRepository(BaseRepository):
self.db.flush() self.db.flush()
return draft 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 @staticmethod
def _build_draft_id() -> str: def _build_draft_id() -> str:
return f"draft_{uuid4().hex[:24]}" return f"draft_{uuid4().hex[:24]}"

@ -94,6 +94,21 @@ class ToolMetadataRepository(BaseRepository):
self.db.flush() self.db.flush()
return metadata 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( def upsert_version_metadata(
self, self,
*, *,
@ -142,3 +157,4 @@ class ToolMetadataRepository(BaseRepository):
def build_metadata_id(tool_name: str, version_number: int) -> str: def build_metadata_id(tool_name: str, version_number: int) -> str:
normalized_tool_name = str(tool_name or "").strip().lower() normalized_tool_name = str(tool_name or "").strip().lower()
return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}" return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}"

@ -35,6 +35,12 @@ class ToolVersionRepository(BaseRepository):
max_version = self.db.execute(statement).scalar_one_or_none() max_version = self.db.execute(statement).scalar_one_or_none()
return int(max_version or 0) + 1 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( def create(
self, self,
*, *,
@ -75,7 +81,23 @@ class ToolVersionRepository(BaseRepository):
self.db.flush() self.db.flush()
return version 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 @staticmethod
def build_version_id(tool_name: str, version_number: int) -> str: def build_version_id(tool_name: str, version_number: int) -> str:
normalized_tool_name = str(tool_name or "").strip().lower() normalized_tool_name = str(tool_name or "").strip().lower()
return f"tool_version::{normalized_tool_name}::v{int(version_number)}" return f"tool_version::{normalized_tool_name}::v{int(version_number)}"

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
import re import re
from dataclasses import dataclass
from datetime import UTC, datetime 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.core.settings import AdminSettings
from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion
from admin_app.db.models.tool_artifact import ( 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_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository
from shared.contracts import ( from shared.contracts import (
AdminPermission,
GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE, GENERATED_TOOLS_PACKAGE,
ServiceName, ServiceName,
StaffRole,
TOOL_LIFECYCLE_STAGES, TOOL_LIFECYCLE_STAGES,
ToolLifecycleStatus, ToolLifecycleStatus,
ToolParameterType, ToolParameterType,
build_generated_tool_module_name, build_generated_tool_module_name,
build_generated_tool_module_path, build_generated_tool_module_path,
) normalize_staff_role,
role_has_permission,
@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.",
),
) )
@ -207,7 +44,15 @@ _PARAMETER_TYPE_DESCRIPTIONS = {
_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") _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}$") _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: class ToolManagementService:
@ -225,6 +70,61 @@ class ToolManagementService:
self.metadata_repository = metadata_repository self.metadata_repository = metadata_repository
self.artifact_repository = artifact_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: def build_overview_payload(self) -> dict:
catalog_payload = self.build_publications_payload() catalog_payload = self.build_publications_payload()
catalog = catalog_payload["publications"] 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 { return {
"mode": "validated_preview", "mode": "validated_preview",
"submission_policy": submission_policy,
"domain_options": [ "domain_options": [
{ {
"value": option.value, "value": option.value,
"label": option.label, "label": option.label,
"description": option.description, "description": option.description,
} }
for option in _INTAKE_DOMAIN_OPTIONS for option in INTAKE_DOMAIN_OPTIONS
], ],
"parameter_types": [ "parameter_types": [
{ {
@ -352,6 +263,7 @@ class ToolManagementService:
], ],
"submission_notes": [ "submission_notes": [
"O colaborador pode preencher, validar e persistir o draft da tool no painel.", "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.", "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.", "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: 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 { return {
"queue_mode": "bootstrap_empty_state", "queue_mode": "governed_admin_queue",
"message": ( "message": 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": [self._serialize_review_queue_entry(version) for version in queued_versions],
), "supported_statuses": list(_REVIEW_QUEUE_STATUSES),
"items": [],
"supported_statuses": [
ToolLifecycleStatus.GENERATED,
ToolLifecycleStatus.VALIDATED,
ToolLifecycleStatus.APPROVED,
ToolLifecycleStatus.FAILED,
],
} }
def build_publications_payload(self) -> dict: def build_publications_payload(self) -> dict:
metadata_entries = self._list_latest_metadata_entries() publications_by_tool_name = {
if metadata_entries: 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 { return {
"source": "admin_metadata_catalog", "source": "hybrid_runtime_catalog",
"target_service": ServiceName.PRODUCT, "target_service": ServiceName.PRODUCT,
"publications": [ "publications": list(publications_by_tool_name.values()),
self._serialize_metadata_publication(metadata)
for metadata in metadata_entries
],
} }
return { return {
"source": "bootstrap_catalog", "source": "bootstrap_catalog",
"target_service": ServiceName.PRODUCT, "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( def create_draft_submission(
@ -425,12 +542,14 @@ class ToolManagementService:
*, *,
owner_staff_account_id: int | None = None, owner_staff_account_id: int | None = None,
owner_name: str | None = None, owner_name: str | None = None,
owner_role: StaffRole | str | None = None,
) -> dict: ) -> dict:
normalized = self._normalize_draft_payload(payload) normalized = self._normalize_draft_payload(payload)
warnings = self._build_intake_warnings(normalized) warnings = self._build_intake_warnings(normalized)
required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"]) required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"])
summary = self._build_draft_summary(normalized) summary = self._build_draft_summary(normalized)
stored_parameters = self._serialize_parameters_for_storage(normalized["parameters"]) 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: if self.draft_repository is None:
version_number = 1 version_number = 1
@ -438,7 +557,8 @@ class ToolManagementService:
version_id = self._build_preview_version_id(normalized["tool_name"], version_number) version_id = self._build_preview_version_id(normalized["tool_name"], version_number)
return { return {
"storage_status": "validated_preview", "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_preview": {
"draft_id": f"preview::{normalized['tool_name']}", "draft_id": f"preview::{normalized['tool_name']}",
"version_id": version_id, "version_id": version_id,
@ -467,10 +587,16 @@ class ToolManagementService:
if owner_staff_account_id is None: if owner_staff_account_id is None:
raise ValueError("owner_staff_account_id e obrigatorio para persistir o draft.") 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"]) 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_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) next_version_count = next_version_number if existing_draft is None else max(existing_draft.version_count + 1, next_version_number)
try:
if existing_draft is None: if existing_draft is None:
draft = self.draft_repository.create( draft = self.draft_repository.create(
tool_name=normalized["tool_name"], tool_name=normalized["tool_name"],
@ -484,8 +610,9 @@ class ToolManagementService:
current_version_number=next_version_number, current_version_number=next_version_number,
version_count=next_version_count, version_count=next_version_count,
owner_staff_account_id=owner_staff_account_id, owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo", owner_display_name=owner_display_name,
requires_director_approval=True, requires_director_approval=True,
**atomic_write_options,
) )
else: else:
draft = self.draft_repository.update_submission( draft = self.draft_repository.update_submission(
@ -500,8 +627,9 @@ class ToolManagementService:
current_version_number=next_version_number, current_version_number=next_version_number,
version_count=next_version_count, version_count=next_version_count,
owner_staff_account_id=owner_staff_account_id, owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo", owner_display_name=owner_display_name,
requires_director_approval=True, requires_director_approval=True,
**atomic_write_options,
) )
version = None version = None
@ -516,9 +644,10 @@ class ToolManagementService:
parameters_json=stored_parameters, parameters_json=stored_parameters,
required_parameter_count=required_parameter_count, required_parameter_count=required_parameter_count,
owner_staff_account_id=owner_staff_account_id, owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo", owner_display_name=owner_display_name,
status=ToolLifecycleStatus.DRAFT, status=ToolLifecycleStatus.DRAFT,
requires_director_approval=True, requires_director_approval=True,
**atomic_write_options,
) )
if version is not None and self.metadata_repository is not None: if version is not None and self.metadata_repository is not None:
@ -534,6 +663,7 @@ class ToolManagementService:
status=version.status, status=version.status,
author_staff_account_id=version.owner_staff_account_id, author_staff_account_id=version.owner_staff_account_id,
author_display_name=version.owner_display_name, author_display_name=version.owner_display_name,
**atomic_write_options,
) )
if version is not None and self.artifact_repository is not None: if version is not None and self.artifact_repository is not None:
@ -545,12 +675,25 @@ class ToolManagementService:
stored_parameters=stored_parameters, stored_parameters=stored_parameters,
required_parameter_count=required_parameter_count, required_parameter_count=required_parameter_count,
owner_staff_account_id=owner_staff_account_id, owner_staff_account_id=owner_staff_account_id,
owner_name=owner_name or "Autor administrativo", 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 { return {
"storage_status": "admin_database", "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), "draft_preview": self._serialize_draft_preview(draft, version),
"warnings": warnings, "warnings": warnings,
"next_steps": [ "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) normalized = self._normalize_draft_payload(payload)
warnings = self._build_intake_warnings(normalized) warnings = self._build_intake_warnings(normalized)
required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"]) required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"])
summary = self._build_draft_summary(normalized) summary = self._build_draft_summary(normalized)
submission_policy = self._build_submission_policy(submitter_role=owner_role)
existing_draft = None existing_draft = None
if self.draft_repository is not None: if self.draft_repository is not None:
existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) 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) version_count = version_number if existing_draft is None else max(existing_draft.version_count + 1, version_number)
return { return {
"storage_status": "validated_preview", "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_preview": {
"draft_id": existing_draft.draft_id if existing_draft is not None else f"preview::{normalized['tool_name']}", "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), "version_id": self._build_preview_version_id(normalized["tool_name"], version_number),
@ -627,9 +778,106 @@ class ToolManagementService:
"published_by": "bootstrap_catalog", "published_by": "bootstrap_catalog",
"published_at": published_at, "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( def _persist_initial_version_artifacts(
self, self,
*, *,
@ -641,10 +889,13 @@ class ToolManagementService:
required_parameter_count: int, required_parameter_count: int,
owner_staff_account_id: int, owner_staff_account_id: int,
owner_name: str, owner_name: str,
commit: bool | None = None,
) -> None: ) -> None:
if self.artifact_repository is None: if self.artifact_repository is None:
return return
artifact_write_options = {"commit": commit} if commit is not None else {}
generation_payload = self._build_generation_artifact_payload( generation_payload = self._build_generation_artifact_payload(
draft=draft, draft=draft,
version=version, version=version,
@ -671,6 +922,7 @@ class ToolManagementService:
payload_json=generation_payload, payload_json=generation_payload,
author_staff_account_id=owner_staff_account_id, author_staff_account_id=owner_staff_account_id,
author_display_name=owner_name, author_display_name=owner_name,
**artifact_write_options,
) )
self.artifact_repository.upsert_version_artifact( self.artifact_repository.upsert_version_artifact(
draft_id=draft.id, draft_id=draft.id,
@ -684,6 +936,7 @@ class ToolManagementService:
payload_json=validation_payload, payload_json=validation_payload,
author_staff_account_id=owner_staff_account_id, author_staff_account_id=owner_staff_account_id,
author_display_name=owner_name, author_display_name=owner_name,
**artifact_write_options,
) )
@staticmethod @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: if self.metadata_repository is None:
return [] return []
latest_by_tool_name: dict[str, ToolMetadata] = {} 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() normalized_tool_name = str(metadata.tool_name or "").strip().lower()
if normalized_tool_name in latest_by_tool_name: if normalized_tool_name in latest_by_tool_name:
continue continue
@ -878,7 +1185,7 @@ class ToolManagementService:
raise ValueError("display_name precisa ter pelo menos 4 caracteres.") raise ValueError("display_name precisa ter pelo menos 4 caracteres.")
domain = str(payload.get("domain") or "").strip().lower() 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: if domain not in valid_domains:
raise ValueError("Selecione um dominio valido para a nova tool.") raise ValueError("Selecione um dominio valido para a nova tool.")

@ -89,7 +89,7 @@ def tool_intake_page(
return _redirect_to_route(request, "admin_login_view") return _redirect_to_route(request, "admin_login_view")
settings = _resolve_settings(request) 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")) 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")) 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)) 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) service = ToolManagementService(settings)
form_payload = service.build_draft_form_payload() form_payload = service.build_draft_form_payload(submitter_role=current_role)
return AdminToolIntakePageView( return AdminToolIntakePageView(
app_name=settings.admin_app_name, app_name=settings.admin_app_name,

@ -368,6 +368,7 @@ function mountToolIntakePage(page) {
function renderDraftPreview(payload) { function renderDraftPreview(payload) {
const draft = payload?.draft_preview; const draft = payload?.draft_preview;
const submissionPolicy = payload?.submission_policy || null;
const warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; const warnings = Array.isArray(payload?.warnings) ? payload.warnings : [];
const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : [];
const parameters = Array.isArray(draft?.parameters) ? draft.parameters : []; const parameters = Array.isArray(draft?.parameters) ? draft.parameters : [];
@ -392,6 +393,9 @@ function mountToolIntakePage(page) {
<div><strong>Obrigatorios:</strong> ${escapeHtml(String(draft?.required_parameter_count || 0))}</div> <div><strong>Obrigatorios:</strong> ${escapeHtml(String(draft?.required_parameter_count || 0))}</div>
<div><strong>Aprovacao:</strong> ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}</div> <div><strong>Aprovacao:</strong> ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}</div>
</div> </div>
${submissionPolicy
? `<div class="admin-tool-inline-note rounded-4 p-3 mb-3"><div class="fw-semibold mb-1">Governanca desta submissao</div><div class="small text-secondary">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>`
: ""}
<div class="vstack gap-2 mb-3"> <div class="vstack gap-2 mb-3">
${parameters.length > 0 ${parameters.length > 0
? parameters.map((item) => `<div class="admin-tool-inline-note rounded-4 p-3"><div class="d-flex justify-content-between gap-3"><div><div class="fw-semibold">${escapeHtml(item.name)}</div><div class="small text-secondary mt-1">${escapeHtml(item.description)}</div></div><span class="badge rounded-pill bg-body-tertiary text-secondary border">${escapeHtml(item.parameter_type)}</span></div></div>`).join("") ? parameters.map((item) => `<div class="admin-tool-inline-note rounded-4 p-3"><div class="d-flex justify-content-between gap-3"><div><div class="fw-semibold">${escapeHtml(item.name)}</div><div class="small text-secondary mt-1">${escapeHtml(item.description)}</div></div><span class="badge rounded-pill bg-body-tertiary text-secondary border">${escapeHtml(item.parameter_type)}</span></div></div>`).join("")

@ -73,6 +73,11 @@ class _FakeToolDraftRepository:
draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, tzinfo=timezone.utc) draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, tzinfo=timezone.utc)
return draft 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: class _FakeToolVersionRepository:
def __init__(self): def __init__(self):
@ -102,6 +107,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name) versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1 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: def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"] version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc) now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc)
@ -116,6 +128,11 @@ class _FakeToolVersionRepository:
self.versions.append(version) self.versions.append(version)
return 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 @staticmethod
def build_version_id(tool_name: str, version_number: int) -> str: def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower() 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) metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, tzinfo=timezone.utc)
return metadata 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: def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None: 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("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/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/panel/tools/drafts/intake", [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): 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) 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) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["storage_status"], "admin_database") 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"]["status"], "draft")
self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo") self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1") 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'.", "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): def test_panel_tools_review_queue_is_available_for_diretor_session(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: 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") response = client.get("/admin/panel/tools/review-queue")
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(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): def test_panel_tools_publications_require_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
@ -461,6 +534,32 @@ class AdminPanelToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'publish_tools'.", "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): def test_panel_tools_publications_return_catalog_for_diretor_session(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: try:
@ -475,7 +574,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertGreaterEqual(len(payload["publications"]), 10) self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]]) 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) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: try:
intake_response = client.post( intake_response = client.post(
@ -503,12 +602,64 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(intake_response.status_code, 200) self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["source"], "admin_metadata_catalog") self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertEqual(len(payload["publications"]), 1) self.assertGreaterEqual(len(payload["publications"]), 10)
publication = payload["publications"][0] 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["publication_id"], "tool_metadata::emitir_resumo_locacao::v1")
self.assertEqual(publication["tool_name"], "emitir_resumo_locacao") self.assertEqual(publication["status"], "active")
self.assertEqual(publication["status"], "draft")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("emitir_resumo_locacao")) self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("emitir_resumo_locacao"))
self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT) self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
self.assertEqual(publication["parameter_count"], 1) self.assertEqual(publication["parameter_count"], 1)

@ -1,13 +1,23 @@
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from admin_app.core import AdminSettings 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.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 admin_app.services.tool_management_service import ToolManagementService
from shared.contracts import ( from shared.contracts import (
AdminPermission,
GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE, GENERATED_TOOLS_PACKAGE,
StaffRole,
ToolLifecycleStatus, ToolLifecycleStatus,
ToolParameterType, ToolParameterType,
build_generated_tool_module_name, 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) draft.updated_at = datetime(2026, 3, 31, 15, current_version_number, tzinfo=timezone.utc)
return draft 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: class _FakeToolVersionRepository:
def __init__(self): def __init__(self):
@ -144,6 +159,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name) versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1 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( def create(
self, self,
*, *,
@ -184,6 +206,11 @@ class _FakeToolVersionRepository:
self.versions.append(version) self.versions.append(version)
return 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 @staticmethod
def build_version_id(tool_name: str, version_number: int) -> str: def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower() 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) metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, tzinfo=timezone.utc)
return metadata 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: def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None: if existing is None:
@ -324,6 +356,19 @@ class _FakeToolArtifactRepository:
return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}" 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): class AdminToolManagementServiceTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.draft_repository = _FakeToolDraftRepository() self.draft_repository = _FakeToolDraftRepository()
@ -363,9 +408,16 @@ class AdminToolManagementServiceTests(unittest.TestCase):
}, },
owner_staff_account_id=7, owner_staff_account_id=7,
owner_name="Equipe Interna", owner_name="Equipe Interna",
owner_role=StaffRole.COLABORADOR,
) )
self.assertEqual(payload["storage_status"], "admin_database") 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"]["draft_id"], "draft_fake_1")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1") self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1")
self.assertEqual(payload["draft_preview"]["version_number"], 1) 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["drafts"][0]["owner_name"], "Diretoria Comercial")
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT]) 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( self.service.create_draft_submission(
{ {
"domain": "revisao", "domain": "revisao",
@ -529,20 +581,265 @@ class AdminToolManagementServiceTests(unittest.TestCase):
payload = self.service.build_publications_payload() 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(payload["target_service"], "product")
self.assertEqual(len(payload["publications"]), 1) self.assertGreaterEqual(len(payload["publications"]), 10)
publication = payload["publications"][0] 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["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
self.assertEqual(publication["tool_name"], "consultar_revisao_aberta")
self.assertEqual(publication["version"], 1) 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["author_name"], "Operacao de Oficina")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta")) self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))
self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT) self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
self.assertEqual(publication["parameter_count"], 1) self.assertEqual(publication["parameter_count"], 1)
self.assertEqual(publication["parameters"][0]["parameter_type"], ToolParameterType.STRING) 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__": if __name__ == "__main__":
unittest.main() unittest.main()

@ -70,6 +70,11 @@ class _FakeToolDraftRepository:
draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, tzinfo=timezone.utc) draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, tzinfo=timezone.utc)
return draft 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: class _FakeToolVersionRepository:
def __init__(self): def __init__(self):
@ -99,6 +104,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name) versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1 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: def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"] version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc) now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc)
@ -113,6 +125,11 @@ class _FakeToolVersionRepository:
self.versions.append(version) self.versions.append(version)
return 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 @staticmethod
def build_version_id(tool_name: str, version_number: int) -> str: def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower() 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) metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, tzinfo=timezone.utc)
return metadata 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: def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"]) existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None: if existing is None:
@ -312,6 +334,8 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("active", [item["code"] for item in payload["workflow"]]) 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/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/tools/drafts/intake", [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()) self.assertIn("artefatos", payload["next_steps"][0].lower())
def test_tools_contracts_return_shared_contract_snapshot(self): def test_tools_contracts_return_shared_contract_snapshot(self):
@ -427,6 +451,12 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["storage_status"], "admin_database") 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"]["status"], "draft")
self.assertEqual(payload["draft_preview"]["domain"], "orquestracao") self.assertEqual(payload["draft_preview"]["domain"], "orquestracao")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::priorizar_contato_quente::v1") 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'.", "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): def test_tools_review_queue_is_available_for_diretor(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: 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"}) response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["queue_mode"], "bootstrap_empty_state") self.assertEqual(payload["queue_mode"], "governed_admin_queue")
self.assertEqual(payload["items"], []) self.assertEqual(len(payload["items"]), 1)
self.assertIn("validated", payload["supported_statuses"]) 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): def test_tools_publications_require_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
@ -481,6 +557,36 @@ class AdminToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'publish_tools'.", "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): def test_tools_publications_return_bootstrap_catalog_for_diretor(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: try:
@ -498,7 +604,7 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(first["status"], "active") self.assertEqual(first["status"], "active")
self.assertEqual(first["implementation_module"], "app.services.tools.handlers") 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) client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try: try:
intake_response = client.post( intake_response = client.post(
@ -527,12 +633,77 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(intake_response.status_code, 200) self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
payload = response.json() payload = response.json()
self.assertEqual(payload["source"], "admin_metadata_catalog") self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertEqual(len(payload["publications"]), 1) self.assertGreaterEqual(len(payload["publications"]), 10)
publication = payload["publications"][0] 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["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
self.assertEqual(publication["tool_name"], "consultar_revisao_aberta") self.assertEqual(publication["status"], "active")
self.assertEqual(publication["status"], "draft")
self.assertEqual(publication["parameter_count"], 1) self.assertEqual(publication["parameter_count"], 1)
self.assertEqual(publication["author_name"], "Equipe de Tools") self.assertEqual(publication["author_name"], "Equipe de Tools")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta")) self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))

Loading…
Cancel
Save