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,
AdminToolDraftIntakeResponse,
AdminToolDraftListResponse,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse,
AdminToolOverviewResponse,
AdminToolPublicationListResponse,
@ -17,7 +18,7 @@ from admin_app.api.schemas import (
)
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService
from shared.contracts import AdminPermission
from shared.contracts import AdminPermission, StaffRole, role_has_permission
router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
@ -29,7 +30,7 @@ router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
def panel_tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends(
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
@ -39,7 +40,7 @@ def panel_tools_overview(
mode=payload["mode"],
metrics=payload["metrics"],
workflow=payload["workflow"],
actions=_build_panel_actions(settings),
actions=_build_panel_actions(settings, current_staff.role),
next_steps=payload["next_steps"],
)
@ -102,6 +103,7 @@ def panel_tool_draft_intake(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
owner_role=current_staff.role,
)
except ValueError as exc:
raise HTTPException(
@ -113,6 +115,7 @@ def panel_tool_draft_intake(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"],
warnings=payload["warnings"],
next_steps=payload["next_steps"],
@ -139,6 +142,62 @@ def panel_tool_review_queue(
)
@router.post(
"/review-queue/{version_id}/review",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_review(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.review_version(
version_id,
reviewer_staff_account_id=current_staff.id,
reviewer_name=current_staff.display_name,
reviewer_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/approve",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_review_queue_approve(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.approve_version(
version_id,
approver_staff_account_id=current_staff.id,
approver_name=current_staff.display_name,
approver_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/publications",
response_model=AdminToolPublicationListResponse,
@ -158,9 +217,54 @@ def panel_tool_publications(
)
@router.post(
"/publications/{version_id}/publish",
response_model=AdminToolGovernanceTransitionResponse,
)
def panel_tool_publications_publish(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.publish_version(
version_id,
publisher_staff_account_id=current_staff.id,
publisher_name=current_staff.display_name,
publisher_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]:
return [
def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse:
return AdminToolGovernanceTransitionResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
queue_entry=payload["queue_entry"],
publication=payload["publication"],
next_steps=payload["next_steps"],
)
def _build_panel_actions(
settings: AdminSettings,
current_role: StaffRole | str | None = None,
) -> list[AdminToolManagementActionResponse]:
actions = [
AdminToolManagementActionResponse(
key="overview",
label="Overview web de tools",
@ -197,7 +301,9 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct
description="Catalogo de tools ativas e prontas para ativacao no produto.",
),
]
if current_role is None:
return actions
return [action for action in actions if role_has_permission(current_role, action.required_permission)]
def _build_prefixed_path(api_prefix: str, path: str) -> str:

@ -10,6 +10,7 @@ from admin_app.api.schemas import (
AdminToolDraftIntakeRequest,
AdminToolDraftIntakeResponse,
AdminToolDraftListResponse,
AdminToolGovernanceTransitionResponse,
AdminToolManagementActionResponse,
AdminToolOverviewResponse,
AdminToolPublicationListResponse,
@ -17,7 +18,7 @@ from admin_app.api.schemas import (
)
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from admin_app.services import ToolManagementService
from shared.contracts import AdminPermission
from shared.contracts import AdminPermission, StaffRole, role_has_permission
router = APIRouter(prefix="/tools", tags=["tools"])
@ -29,7 +30,7 @@ router = APIRouter(prefix="/tools", tags=["tools"])
def tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends(
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
@ -39,7 +40,7 @@ def tools_overview(
mode=payload["mode"],
metrics=payload["metrics"],
workflow=payload["workflow"],
actions=_build_actions(settings),
actions=_build_actions(settings, current_staff.role),
next_steps=payload["next_steps"],
)
@ -102,6 +103,7 @@ def tool_draft_intake(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
owner_role=current_staff.role,
)
except ValueError as exc:
raise HTTPException(
@ -113,6 +115,7 @@ def tool_draft_intake(
service="orquestrador-admin",
storage_status=payload["storage_status"],
message=payload["message"],
submission_policy=payload["submission_policy"],
draft_preview=payload["draft_preview"],
warnings=payload["warnings"],
next_steps=payload["next_steps"],
@ -139,6 +142,62 @@ def tool_review_queue(
)
@router.post(
"/review-queue/{version_id}/review",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_review(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.review_version(
version_id,
reviewer_staff_account_id=current_staff.id,
reviewer_name=current_staff.display_name,
reviewer_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.post(
"/review-queue/{version_id}/approve",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_review_queue_approve(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
try:
payload = service.approve_version(
version_id,
approver_staff_account_id=current_staff.id,
approver_name=current_staff.display_name,
approver_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
@router.get(
"/publications",
response_model=AdminToolPublicationListResponse,
@ -158,8 +217,54 @@ def tool_publications(
)
def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]:
return [
@router.post(
"/publications/{version_id}/publish",
response_model=AdminToolGovernanceTransitionResponse,
)
def tool_publications_publish(
version_id: str,
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
try:
payload = service.publish_version(
version_id,
publisher_staff_account_id=current_staff.id,
publisher_name=current_staff.display_name,
publisher_role=current_staff.role,
)
except LookupError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
except PermissionError as exc:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
return _build_governance_transition_response(payload)
def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse:
return AdminToolGovernanceTransitionResponse(
service="orquestrador-admin",
message=payload["message"],
version_id=payload["version_id"],
tool_name=payload["tool_name"],
version_number=payload["version_number"],
status=payload["status"],
queue_entry=payload["queue_entry"],
publication=payload["publication"],
next_steps=payload["next_steps"],
)
def _build_actions(
settings: AdminSettings,
current_role: StaffRole | str | None = None,
) -> list[AdminToolManagementActionResponse]:
actions = [
AdminToolManagementActionResponse(
key="overview",
label="Overview de tools",
@ -203,7 +308,9 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes
description="Catalogo bootstrap de tools ativas voltadas ao runtime de produto.",
),
]
if current_role is None:
return actions
return [action for action in actions if role_has_permission(current_role, action.required_permission)]
def _build_prefixed_path(api_prefix: str, path: str) -> str:

@ -755,6 +755,8 @@ class AdminToolDraftListResponse(BaseModel):
class AdminToolReviewQueueEntryResponse(BaseModel):
entry_id: str
version_id: str
version_number: int = Field(ge=1)
tool_name: str
display_name: str
status: ToolLifecycleStatus
@ -802,6 +804,19 @@ class AdminToolPublicationListResponse(BaseModel):
target_service: ServiceName
publications: list[AdminToolPublicationSummaryResponse]
class AdminToolGovernanceTransitionResponse(BaseModel):
service: str
message: str
version_id: str
tool_name: str
version_number: int = Field(ge=1)
status: ToolLifecycleStatus
queue_entry: AdminToolReviewQueueEntryResponse | None = None
publication: AdminToolPublicationSummaryResponse | None = None
next_steps: list[str]
class AdminToolDraftIntakeParameterRequest(BaseModel):
name: str = Field(min_length=1, max_length=64)
parameter_type: ToolParameterType
@ -843,6 +858,17 @@ class AdminToolDraftIntakeRequest(BaseModel):
return value.strip().lower()
class AdminToolDraftSubmissionPolicyResponse(BaseModel):
mode: str
submitter_role: StaffRole | None = None
submitter_can_publish_now: bool
direct_publication_blocked: bool
requires_director_approval: bool
required_approver_role: StaffRole
required_review_permission: AdminPermission
required_publish_permission: AdminPermission
class AdminToolDraftIntakePreviewParameterResponse(BaseModel):
name: str
parameter_type: ToolParameterType
@ -872,6 +898,7 @@ class AdminToolDraftIntakeResponse(BaseModel):
service: str
storage_status: str
message: str
submission_policy: AdminToolDraftSubmissionPolicyResponse
draft_preview: AdminToolDraftIntakePreviewResponse
warnings: list[str]
next_steps: list[str]

@ -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):
GENERATION = "generation"
VALIDATION = "validation"
GOVERNANCE = "governance"
class ToolArtifactKind(str, Enum):
GENERATION_REQUEST = "generation_request"
VALIDATION_REPORT = "validation_report"
DIRECTOR_REVIEW = "director_review"
DIRECTOR_APPROVAL = "director_approval"
PUBLICATION_RELEASE = "publication_release"
class ToolArtifactStorageKind(str, Enum):
@ -111,3 +115,4 @@ class ToolArtifact(AdminTimestampedModel):
index=True,
)
author_display_name: Mapped[str] = mapped_column(String(150), nullable=False)

@ -108,6 +108,22 @@ class ToolDraftRepository(BaseRepository):
self.db.flush()
return draft
def update_status(
self,
draft: ToolDraft,
*,
status: ToolLifecycleStatus,
commit: bool = True,
) -> ToolDraft:
draft.status = status
if commit:
self.db.commit()
self.db.refresh(draft)
else:
self.db.flush()
return draft
@staticmethod
def _build_draft_id() -> str:
return f"draft_{uuid4().hex[:24]}"

@ -94,6 +94,21 @@ class ToolMetadataRepository(BaseRepository):
self.db.flush()
return metadata
def update_status(
self,
metadata: ToolMetadata,
*,
status: ToolLifecycleStatus,
commit: bool = True,
) -> ToolMetadata:
metadata.status = status
if commit:
self.db.commit()
self.db.refresh(metadata)
else:
self.db.flush()
return metadata
def upsert_version_metadata(
self,
*,
@ -142,3 +157,4 @@ class ToolMetadataRepository(BaseRepository):
def build_metadata_id(tool_name: str, version_number: int) -> str:
normalized_tool_name = str(tool_name or "").strip().lower()
return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}"

@ -35,6 +35,12 @@ class ToolVersionRepository(BaseRepository):
max_version = self.db.execute(statement).scalar_one_or_none()
return int(max_version or 0) + 1
def get_by_version_id(self, version_id: str) -> ToolVersion | None:
statement = select(ToolVersion).where(
ToolVersion.version_id == str(version_id or "").strip().lower()
)
return self.db.execute(statement).scalar_one_or_none()
def create(
self,
*,
@ -75,7 +81,23 @@ class ToolVersionRepository(BaseRepository):
self.db.flush()
return version
def update_status(
self,
version: ToolVersion,
*,
status: ToolLifecycleStatus,
commit: bool = True,
) -> ToolVersion:
version.status = status
if commit:
self.db.commit()
self.db.refresh(version)
else:
self.db.flush()
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized_tool_name = str(tool_name or "").strip().lower()
return f"tool_version::{normalized_tool_name}::v{int(version_number)}"

File diff suppressed because it is too large Load Diff

@ -89,7 +89,7 @@ def tool_intake_page(
return _redirect_to_route(request, "admin_login_view")
settings = _resolve_settings(request)
view = _build_tool_intake_view(request, settings)
view = _build_tool_intake_view(request, settings, current_context.principal.role)
css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css"))
js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js"))
return HTMLResponse(render_tool_intake_page(view, css_href=css_href, js_href=js_href))
@ -661,9 +661,13 @@ def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPa
)
def _build_tool_intake_view(request: Request, settings: AdminSettings) -> AdminToolIntakePageView:
def _build_tool_intake_view(
request: Request,
settings: AdminSettings,
current_role: StaffRole | str | None,
) -> AdminToolIntakePageView:
service = ToolManagementService(settings)
form_payload = service.build_draft_form_payload()
form_payload = service.build_draft_form_payload(submitter_role=current_role)
return AdminToolIntakePageView(
app_name=settings.admin_app_name,

@ -368,6 +368,7 @@ function mountToolIntakePage(page) {
function renderDraftPreview(payload) {
const draft = payload?.draft_preview;
const submissionPolicy = payload?.submission_policy || null;
const warnings = Array.isArray(payload?.warnings) ? payload.warnings : [];
const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : [];
const parameters = Array.isArray(draft?.parameters) ? draft.parameters : [];
@ -392,6 +393,9 @@ function mountToolIntakePage(page) {
<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>
${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">
${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("")

@ -73,6 +73,11 @@ class _FakeToolDraftRepository:
draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, tzinfo=timezone.utc)
return draft
def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft:
draft.status = status
draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, 30, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
@ -102,6 +107,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def get_by_version_id(self, version_id: str) -> ToolVersion | None:
normalized = str(version_id or "").strip().lower()
for version in self.versions:
if version.version_id == normalized:
return version
return None
def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc)
@ -116,6 +128,11 @@ class _FakeToolVersionRepository:
self.versions.append(version)
return version
def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion:
version.status = status
version.updated_at = datetime(2026, 3, 31, 18, version.version_number, 30, tzinfo=timezone.utc)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
@ -175,6 +192,11 @@ class _FakeToolMetadataRepository:
metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, tzinfo=timezone.utc)
return metadata
def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata:
metadata.status = status
metadata.updated_at = datetime(2026, 3, 31, 19, metadata.version_number, 30, tzinfo=timezone.utc)
return metadata
def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None:
@ -312,6 +334,8 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertIn("persisted_artifacts", [item["key"] for item in payload["metrics"]])
self.assertIn("/admin/panel/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/panel/tools/drafts/intake", [item["href"] for item in payload["actions"]])
self.assertNotIn("/admin/panel/tools/review-queue", [item["href"] for item in payload["actions"]])
self.assertNotIn("/admin/panel/tools/publications", [item["href"] for item in payload["actions"]])
def test_panel_tool_intake_blocks_tool_name_reserved_by_core_catalog_for_colaborador(self):
client, app, draft_repository, version_repository, metadata_repository, artifact_repository = self._build_client_with_role(StaffRole.COLABORADOR)
@ -370,6 +394,12 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(payload["submission_policy"]["mode"], "draft_only")
self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador")
self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"])
self.assertTrue(payload["submission_policy"]["direct_publication_blocked"])
self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor")
self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools")
self.assertEqual(payload["draft_preview"]["status"], "draft")
self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1")
@ -438,15 +468,58 @@ class AdminPanelToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'review_tool_generations'.",
)
def test_panel_tools_review_action_requires_director_session(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
intake_response = client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume contratos de locacao com filtros operacionais para o time interno.",
"business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
"parameters": [],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review")
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 403)
self.assertEqual(
response.json()["detail"],
"Permissao administrativa insuficiente: 'review_tool_generations'.",
)
def test_panel_tools_review_queue_is_available_for_diretor_session(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume contratos de locacao com filtros operacionais para o time interno.",
"business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
"parameters": [],
},
)
response = client.get("/admin/panel/tools/review-queue")
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state")
payload = response.json()
self.assertEqual(payload["queue_mode"], "governed_admin_queue")
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["status"], "draft")
self.assertEqual(payload["items"][0]["gate"], "director_review_required")
self.assertEqual(payload["items"][0]["version_number"], 1)
def test_panel_tools_publications_require_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
@ -461,6 +534,32 @@ class AdminPanelToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'publish_tools'.",
)
def test_panel_tools_publish_action_requires_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
intake_response = client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume contratos de locacao com filtros operacionais para o time interno.",
"business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
"parameters": [],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
response = client.post(f"/admin/panel/tools/publications/{version_id}/publish")
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 403)
self.assertEqual(
response.json()["detail"],
"Permissao administrativa insuficiente: 'publish_tools'.",
)
def test_panel_tools_publications_return_catalog_for_diretor_session(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
@ -475,7 +574,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]])
def test_panel_tools_publications_prefer_persisted_metadata_for_diretor_session(self):
def test_panel_tools_publications_keep_bootstrap_catalog_after_intake(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
@ -503,12 +602,64 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["source"], "admin_metadata_catalog")
self.assertEqual(len(payload["publications"]), 1)
publication = payload["publications"][0]
self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in payload["publications"]])
def test_panel_tools_director_workflow_reviews_approves_and_publishes_before_activation(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume contratos de locacao com filtros operacionais para o time interno.",
"business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
"parameters": [
{
"name": "contrato_id",
"parameter_type": "string",
"description": "Identificador do contrato consultado.",
"required": True,
}
],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
publish_before_approval = client.post(f"/admin/panel/tools/publications/{version_id}/publish")
review_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review")
approve_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/approve")
pre_publications = client.get("/admin/panel/tools/publications")
publish_response = client.post(f"/admin/panel/tools/publications/{version_id}/publish")
final_publications = client.get("/admin/panel/tools/publications")
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(publish_before_approval.status_code, 409)
self.assertIn("approved", publish_before_approval.json()["detail"])
self.assertEqual(review_response.status_code, 200)
self.assertEqual(review_response.json()["status"], "validated")
self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required")
self.assertEqual(approve_response.status_code, 200)
self.assertEqual(approve_response.json()["status"], "approved")
self.assertEqual(approve_response.json()["queue_entry"]["gate"], "director_publication_required")
self.assertEqual(pre_publications.status_code, 200)
self.assertEqual(pre_publications.json()["source"], "bootstrap_catalog")
self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in pre_publications.json()["publications"]])
self.assertEqual(publish_response.status_code, 200)
self.assertEqual(publish_response.json()["status"], "active")
self.assertIsNone(publish_response.json()["queue_entry"])
self.assertEqual(publish_response.json()["publication"]["tool_name"], "emitir_resumo_locacao")
self.assertEqual(final_publications.status_code, 200)
payload = final_publications.json()
self.assertEqual(payload["source"], "hybrid_runtime_catalog")
self.assertGreaterEqual(len(payload["publications"]), 11)
publication = next(item for item in payload["publications"] if item["tool_name"] == "emitir_resumo_locacao")
self.assertEqual(publication["publication_id"], "tool_metadata::emitir_resumo_locacao::v1")
self.assertEqual(publication["tool_name"], "emitir_resumo_locacao")
self.assertEqual(publication["status"], "draft")
self.assertEqual(publication["status"], "active")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("emitir_resumo_locacao"))
self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
self.assertEqual(publication["parameter_count"], 1)

@ -1,13 +1,23 @@
import unittest
from datetime import datetime, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from admin_app.core import AdminSettings
from admin_app.db.models import ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
from admin_app.db.database import AdminBase
from admin_app.db.models import StaffAccount, ToolArtifact, ToolDraft, ToolMetadata, ToolVersion
from admin_app.db.models.tool_artifact import ToolArtifactKind
from admin_app.repositories.tool_artifact_repository import ToolArtifactRepository
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository
from admin_app.services.tool_management_service import ToolManagementService
from shared.contracts import (
AdminPermission,
GENERATED_TOOL_ENTRYPOINT,
GENERATED_TOOLS_PACKAGE,
StaffRole,
ToolLifecycleStatus,
ToolParameterType,
build_generated_tool_module_name,
@ -115,6 +125,11 @@ class _FakeToolDraftRepository:
draft.updated_at = datetime(2026, 3, 31, 15, current_version_number, tzinfo=timezone.utc)
return draft
def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft:
draft.status = status
draft.updated_at = datetime(2026, 3, 31, 15, draft.current_version_number, 30, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
@ -144,6 +159,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def get_by_version_id(self, version_id: str) -> ToolVersion | None:
normalized = str(version_id or "").strip().lower()
for version in self.versions:
if version.version_id == normalized:
return version
return None
def create(
self,
*,
@ -184,6 +206,11 @@ class _FakeToolVersionRepository:
self.versions.append(version)
return version
def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion:
version.status = status
version.updated_at = datetime(2026, 3, 31, 16, version.version_number, 30, tzinfo=timezone.utc)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
@ -243,6 +270,11 @@ class _FakeToolMetadataRepository:
metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, tzinfo=timezone.utc)
return metadata
def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata:
metadata.status = status
metadata.updated_at = datetime(2026, 3, 31, 17, metadata.version_number, 30, tzinfo=timezone.utc)
return metadata
def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None:
@ -324,6 +356,19 @@ class _FakeToolArtifactRepository:
return f"tool_artifact::{normalized}::v{int(version_number)}::{artifact_kind.value}"
class _FailingToolArtifactRepository(ToolArtifactRepository):
def __init__(self, db, *, fail_on_call: int):
super().__init__(db)
self.fail_on_call = fail_on_call
self.calls = 0
def upsert_version_artifact(self, **kwargs):
self.calls += 1
if self.calls == self.fail_on_call:
raise RuntimeError("artifact persistence failure")
return super().upsert_version_artifact(**kwargs)
class AdminToolManagementServiceTests(unittest.TestCase):
def setUp(self):
self.draft_repository = _FakeToolDraftRepository()
@ -363,9 +408,16 @@ class AdminToolManagementServiceTests(unittest.TestCase):
},
owner_staff_account_id=7,
owner_name="Equipe Interna",
owner_role=StaffRole.COLABORADOR,
)
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(payload["submission_policy"]["mode"], "draft_only")
self.assertEqual(payload["submission_policy"]["submitter_role"], StaffRole.COLABORADOR)
self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"])
self.assertTrue(payload["submission_policy"]["direct_publication_blocked"])
self.assertEqual(payload["submission_policy"]["required_approver_role"], StaffRole.DIRETOR)
self.assertEqual(payload["submission_policy"]["required_publish_permission"], AdminPermission.PUBLISH_TOOLS)
self.assertEqual(payload["draft_preview"]["draft_id"], "draft_fake_1")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::consultar_vendas_periodo::v1")
self.assertEqual(payload["draft_preview"]["version_number"], 1)
@ -506,7 +558,7 @@ class AdminToolManagementServiceTests(unittest.TestCase):
self.assertEqual(payload["drafts"][0]["owner_name"], "Diretoria Comercial")
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT])
def test_build_publications_payload_prefers_persisted_metadata_catalog(self):
def test_build_publications_payload_keeps_bootstrap_catalog_for_draft_metadata(self):
self.service.create_draft_submission(
{
"domain": "revisao",
@ -529,20 +581,265 @@ class AdminToolManagementServiceTests(unittest.TestCase):
payload = self.service.build_publications_payload()
self.assertEqual(payload["source"], "admin_metadata_catalog")
self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertEqual(payload["target_service"], "product")
self.assertEqual(len(payload["publications"]), 1)
publication = payload["publications"][0]
self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in payload["publications"]])
def test_build_publications_payload_merges_active_governed_publications_with_bootstrap_catalog(self):
self.service.create_draft_submission(
{
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [
{
"name": "placa",
"parameter_type": "string",
"description": "Placa usada na busca da revisao.",
"required": True,
}
],
},
owner_staff_account_id=8,
owner_name="Operacao de Oficina",
)
self.metadata_repository.metadata_entries[0].status = ToolLifecycleStatus.ACTIVE
payload = self.service.build_publications_payload()
self.assertEqual(payload["source"], "hybrid_runtime_catalog")
self.assertEqual(payload["target_service"], "product")
self.assertGreaterEqual(len(payload["publications"]), 11)
self.assertIn("consultar_estoque", [item["tool_name"] for item in payload["publications"]])
publication = next(
item for item in payload["publications"] if item["tool_name"] == "consultar_revisao_aberta"
)
self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
self.assertEqual(publication["tool_name"], "consultar_revisao_aberta")
self.assertEqual(publication["version"], 1)
self.assertEqual(publication["status"], ToolLifecycleStatus.DRAFT)
self.assertEqual(publication["status"], ToolLifecycleStatus.ACTIVE)
self.assertEqual(publication["author_name"], "Operacao de Oficina")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))
self.assertEqual(publication["implementation_callable"], GENERATED_TOOL_ENTRYPOINT)
self.assertEqual(publication["parameter_count"], 1)
self.assertEqual(publication["parameters"][0]["parameter_type"], ToolParameterType.STRING)
def test_build_review_queue_payload_returns_latest_version_pending_director_action(self):
intake_payload = self.service.create_draft_submission(
{
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [
{
"name": "placa",
"parameter_type": "string",
"description": "Placa usada na busca da revisao.",
"required": True,
}
],
},
owner_staff_account_id=8,
owner_name="Operacao de Oficina",
)
payload = self.service.build_review_queue_payload()
self.assertEqual(payload["queue_mode"], "governed_admin_queue")
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["version_id"], intake_payload["draft_preview"]["version_id"])
self.assertEqual(payload["items"][0]["version_number"], 1)
self.assertEqual(payload["items"][0]["status"], ToolLifecycleStatus.DRAFT)
self.assertEqual(payload["items"][0]["gate"], "director_review_required")
self.assertIn(ToolLifecycleStatus.APPROVED, payload["supported_statuses"])
def test_director_must_review_approve_and_publish_before_activation(self):
intake_payload = self.service.create_draft_submission(
{
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume contratos de locacao com filtros operacionais para o time interno.",
"business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.",
"parameters": [
{
"name": "contrato_id",
"parameter_type": "string",
"description": "Identificador do contrato consultado.",
"required": True,
}
],
},
owner_staff_account_id=3,
owner_name="Equipe Interna",
)
version_id = intake_payload["draft_preview"]["version_id"]
review_payload = self.service.review_version(
version_id,
reviewer_staff_account_id=99,
reviewer_name="Diretoria",
reviewer_role=StaffRole.DIRETOR,
)
approve_payload = self.service.approve_version(
version_id,
approver_staff_account_id=99,
approver_name="Diretoria",
approver_role=StaffRole.DIRETOR,
)
publish_payload = self.service.publish_version(
version_id,
publisher_staff_account_id=99,
publisher_name="Diretoria",
publisher_role=StaffRole.DIRETOR,
)
self.assertEqual(review_payload["status"], ToolLifecycleStatus.VALIDATED)
self.assertEqual(review_payload["queue_entry"]["gate"], "director_approval_required")
self.assertEqual(approve_payload["status"], ToolLifecycleStatus.APPROVED)
self.assertEqual(approve_payload["queue_entry"]["gate"], "director_publication_required")
self.assertEqual(publish_payload["status"], ToolLifecycleStatus.ACTIVE)
self.assertIsNone(publish_payload["queue_entry"])
self.assertEqual(publish_payload["publication"]["tool_name"], "emitir_resumo_locacao")
self.assertEqual(self.draft_repository.drafts[0].status, ToolLifecycleStatus.ACTIVE)
self.assertEqual(self.version_repository.versions[0].status, ToolLifecycleStatus.ACTIVE)
self.assertEqual(self.metadata_repository.metadata_entries[0].status, ToolLifecycleStatus.ACTIVE)
artifact_kinds = {artifact.artifact_kind for artifact in self.artifact_repository.artifacts}
self.assertIn(ToolArtifactKind.DIRECTOR_REVIEW, artifact_kinds)
self.assertIn(ToolArtifactKind.DIRECTOR_APPROVAL, artifact_kinds)
self.assertIn(ToolArtifactKind.PUBLICATION_RELEASE, artifact_kinds)
def test_publish_requires_prior_director_review_and_approval(self):
intake_payload = self.service.create_draft_submission(
{
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [],
},
owner_staff_account_id=8,
owner_name="Operacao de Oficina",
)
with self.assertRaisesRegex(ValueError, "approved"):
self.service.publish_version(
intake_payload["draft_preview"]["version_id"],
publisher_staff_account_id=99,
publisher_name="Diretoria",
publisher_role=StaffRole.DIRETOR,
)
def test_publishing_new_version_archives_previous_active_version(self):
first_intake = self.service.create_draft_submission(
{
"domain": "vendas",
"tool_name": "consultar_funil_comercial",
"display_name": "Consultar funil comercial",
"description": "Consulta o funil comercial consolidado para acompanhamento administrativo.",
"business_goal": "Dar visibilidade ao time interno sobre os principais gargalos do funil.",
"parameters": [],
},
owner_staff_account_id=7,
owner_name="Equipe Interna",
)
first_version_id = first_intake["draft_preview"]["version_id"]
self.service.review_version(first_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR)
self.service.approve_version(first_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR)
self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR)
second_intake = self.service.create_draft_submission(
{
"domain": "vendas",
"tool_name": "consultar_funil_comercial",
"display_name": "Consultar funil comercial",
"description": "Consulta o funil comercial consolidado com campos adicionais para acompanhamento administrativo.",
"business_goal": "Dar visibilidade ao time interno sobre gargalos, volume e conversao do funil.",
"parameters": [],
},
owner_staff_account_id=7,
owner_name="Equipe Interna",
)
second_version_id = second_intake["draft_preview"]["version_id"]
self.service.review_version(second_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR)
self.service.approve_version(second_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR)
self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR)
versions_by_number = {version.version_number: version for version in self.version_repository.versions}
metadata_by_number = {metadata.version_number: metadata for metadata in self.metadata_repository.metadata_entries}
self.assertEqual(versions_by_number[1].status, ToolLifecycleStatus.ARCHIVED)
self.assertEqual(metadata_by_number[1].status, ToolLifecycleStatus.ARCHIVED)
self.assertEqual(versions_by_number[2].status, ToolLifecycleStatus.ACTIVE)
self.assertEqual(metadata_by_number[2].status, ToolLifecycleStatus.ACTIVE)
class AdminToolManagementTransactionalPersistenceTests(unittest.TestCase):
def setUp(self):
self.engine = create_engine("sqlite:///:memory:")
AdminBase.metadata.create_all(bind=self.engine)
session_local = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
self.db = session_local()
owner = StaffAccount(
email="tools-admin@example.com",
display_name="Equipe Interna",
password_hash="hash",
role=StaffRole.COLABORADOR,
is_active=True,
)
self.db.add(owner)
self.db.commit()
self.db.refresh(owner)
self.owner = owner
self.draft_repository = ToolDraftRepository(self.db)
self.version_repository = ToolVersionRepository(self.db)
self.metadata_repository = ToolMetadataRepository(self.db)
self.artifact_repository = _FailingToolArtifactRepository(self.db, fail_on_call=2)
self.service = ToolManagementService(
settings=AdminSettings(admin_api_prefix="/admin"),
draft_repository=self.draft_repository,
version_repository=self.version_repository,
metadata_repository=self.metadata_repository,
artifact_repository=self.artifact_repository,
)
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_create_draft_submission_rolls_back_all_rows_when_artifact_persistence_fails(self):
with self.assertRaisesRegex(RuntimeError, "artifact persistence failure"):
self.service.create_draft_submission(
{
"domain": "vendas",
"tool_name": "consolidar_funil_interno",
"display_name": "Consolidar funil interno",
"description": "Consolida indicadores internos do funil comercial para acompanhamento administrativo.",
"business_goal": "Permitir que o time acompanhe a saude do funil sem depender de consultas manuais repetidas.",
"parameters": [
{
"name": "periodo_inicio",
"parameter_type": "string",
"description": "Data inicial usada na consolidacao.",
"required": True,
}
],
},
owner_staff_account_id=self.owner.id,
owner_name=self.owner.display_name,
)
self.assertEqual(self.draft_repository.list_drafts(), [])
self.assertEqual(self.version_repository.list_versions(), [])
self.assertEqual(self.metadata_repository.list_metadata(), [])
self.assertEqual(self.artifact_repository.list_artifacts(), [])
if __name__ == "__main__":
unittest.main()

@ -70,6 +70,11 @@ class _FakeToolDraftRepository:
draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, tzinfo=timezone.utc)
return draft
def update_status(self, draft: ToolDraft, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolDraft:
draft.status = status
draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, 30, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
@ -99,6 +104,13 @@ class _FakeToolVersionRepository:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def get_by_version_id(self, version_id: str) -> ToolVersion | None:
normalized = str(version_id or "").strip().lower()
for version in self.versions:
if version.version_id == normalized:
return version
return None
def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc)
@ -113,6 +125,11 @@ class _FakeToolVersionRepository:
self.versions.append(version)
return version
def update_status(self, version: ToolVersion, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolVersion:
version.status = status
version.updated_at = datetime(2026, 3, 31, 17, version.version_number, 30, tzinfo=timezone.utc)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
@ -172,6 +189,11 @@ class _FakeToolMetadataRepository:
metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, tzinfo=timezone.utc)
return metadata
def update_status(self, metadata: ToolMetadata, *, status: ToolLifecycleStatus, commit: bool = True) -> ToolMetadata:
metadata.status = status
metadata.updated_at = datetime(2026, 3, 31, 18, metadata.version_number, 30, tzinfo=timezone.utc)
return metadata
def upsert_version_metadata(self, **kwargs) -> ToolMetadata:
existing = self.get_by_tool_version_id(kwargs["tool_version_id"])
if existing is None:
@ -312,6 +334,8 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("active", [item["code"] for item in payload["workflow"]])
self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]])
self.assertNotIn("/admin/tools/review-queue", [item["href"] for item in payload["actions"]])
self.assertNotIn("/admin/tools/publications", [item["href"] for item in payload["actions"]])
self.assertIn("artefatos", payload["next_steps"][0].lower())
def test_tools_contracts_return_shared_contract_snapshot(self):
@ -427,6 +451,12 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(payload["submission_policy"]["mode"], "draft_only")
self.assertEqual(payload["submission_policy"]["submitter_role"], "colaborador")
self.assertFalse(payload["submission_policy"]["submitter_can_publish_now"])
self.assertTrue(payload["submission_policy"]["direct_publication_blocked"])
self.assertEqual(payload["submission_policy"]["required_approver_role"], "diretor")
self.assertEqual(payload["submission_policy"]["required_publish_permission"], "publish_tools")
self.assertEqual(payload["draft_preview"]["status"], "draft")
self.assertEqual(payload["draft_preview"]["domain"], "orquestracao")
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::priorizar_contato_quente::v1")
@ -455,18 +485,64 @@ class AdminToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'review_tool_generations'.",
)
def test_tools_review_action_requires_director_review_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
intake_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
response = client.post(
f"/admin/tools/review-queue/{version_id}/review",
headers={"Authorization": "Bearer token"},
)
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 403)
self.assertEqual(
response.json()["detail"],
"Permissao administrativa insuficiente: 'review_tool_generations'.",
)
def test_tools_review_queue_is_available_for_diretor(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [],
},
)
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["queue_mode"], "bootstrap_empty_state")
self.assertEqual(payload["items"], [])
self.assertIn("validated", payload["supported_statuses"])
self.assertEqual(payload["queue_mode"], "governed_admin_queue")
self.assertEqual(len(payload["items"]), 1)
self.assertEqual(payload["items"][0]["status"], "draft")
self.assertEqual(payload["items"][0]["gate"], "director_review_required")
self.assertEqual(payload["items"][0]["version_number"], 1)
self.assertIn("approved", payload["supported_statuses"])
def test_tools_publications_require_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
@ -481,6 +557,36 @@ class AdminToolsWebTests(unittest.TestCase):
"Permissao administrativa insuficiente: 'publish_tools'.",
)
def test_tools_publish_action_requires_director_publication_permission(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
intake_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
response = client.post(
f"/admin/tools/publications/{version_id}/publish",
headers={"Authorization": "Bearer token"},
)
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 403)
self.assertEqual(
response.json()["detail"],
"Permissao administrativa insuficiente: 'publish_tools'.",
)
def test_tools_publications_return_bootstrap_catalog_for_diretor(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
@ -498,7 +604,7 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(first["status"], "active")
self.assertEqual(first["implementation_module"], "app.services.tools.handlers")
def test_tools_publications_prefer_persisted_metadata_after_intake(self):
def test_tools_publications_keep_bootstrap_catalog_after_intake(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
@ -527,12 +633,77 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["source"], "admin_metadata_catalog")
self.assertEqual(len(payload["publications"]), 1)
publication = payload["publications"][0]
self.assertEqual(payload["source"], "bootstrap_catalog")
self.assertGreaterEqual(len(payload["publications"]), 10)
self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in payload["publications"]])
def test_tools_director_workflow_reviews_approves_and_publishes_before_activation(self):
client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
intake_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "revisao",
"tool_name": "consultar_revisao_aberta",
"display_name": "Consultar revisao aberta",
"description": "Consulta revisoes abertas com filtros administrativos para a oficina.",
"business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.",
"parameters": [
{
"name": "placa",
"parameter_type": "string",
"description": "Placa usada na busca da revisao.",
"required": True,
}
],
},
)
version_id = intake_response.json()["draft_preview"]["version_id"]
publish_before_approval = client.post(
f"/admin/tools/publications/{version_id}/publish",
headers={"Authorization": "Bearer token"},
)
review_response = client.post(
f"/admin/tools/review-queue/{version_id}/review",
headers={"Authorization": "Bearer token"},
)
approve_response = client.post(
f"/admin/tools/review-queue/{version_id}/approve",
headers={"Authorization": "Bearer token"},
)
pre_publications = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
publish_response = client.post(
f"/admin/tools/publications/{version_id}/publish",
headers={"Authorization": "Bearer token"},
)
final_publications = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally:
app.dependency_overrides.clear()
self.assertEqual(intake_response.status_code, 200)
self.assertEqual(publish_before_approval.status_code, 409)
self.assertIn("approved", publish_before_approval.json()["detail"])
self.assertEqual(review_response.status_code, 200)
self.assertEqual(review_response.json()["status"], "validated")
self.assertEqual(review_response.json()["queue_entry"]["gate"], "director_approval_required")
self.assertEqual(approve_response.status_code, 200)
self.assertEqual(approve_response.json()["status"], "approved")
self.assertEqual(approve_response.json()["queue_entry"]["gate"], "director_publication_required")
self.assertEqual(pre_publications.status_code, 200)
self.assertEqual(pre_publications.json()["source"], "bootstrap_catalog")
self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in pre_publications.json()["publications"]])
self.assertEqual(publish_response.status_code, 200)
self.assertEqual(publish_response.json()["status"], "active")
self.assertIsNone(publish_response.json()["queue_entry"])
self.assertEqual(publish_response.json()["publication"]["tool_name"], "consultar_revisao_aberta")
self.assertEqual(final_publications.status_code, 200)
payload = final_publications.json()
self.assertEqual(payload["source"], "hybrid_runtime_catalog")
self.assertGreaterEqual(len(payload["publications"]), 11)
publication = next(item for item in payload["publications"] if item["tool_name"] == "consultar_revisao_aberta")
self.assertEqual(publication["publication_id"], "tool_metadata::consultar_revisao_aberta::v1")
self.assertEqual(publication["tool_name"], "consultar_revisao_aberta")
self.assertEqual(publication["status"], "draft")
self.assertEqual(publication["status"], "active")
self.assertEqual(publication["parameter_count"], 1)
self.assertEqual(publication["author_name"], "Equipe de Tools")
self.assertEqual(publication["implementation_module"], build_generated_tool_module_name("consultar_revisao_aberta"))

Loading…
Cancel
Save