feat(admin): iniciar governanca versionada de tools na fase 5

feat/self-evolving-tools-foundation
parent d6e765ce3c
commit b3662906bc

@ -11,8 +11,19 @@ from admin_app.core import (
get_admin_settings,
)
from admin_app.db.database import get_admin_db_session
from admin_app.repositories import AuditLogRepository, StaffAccountRepository, StaffSessionRepository
from admin_app.services import AuditService, AuthService, CollaboratorManagementService
from admin_app.repositories import (
AuditLogRepository,
StaffAccountRepository,
StaffSessionRepository,
ToolDraftRepository,
ToolVersionRepository,
)
from admin_app.services import (
AuditService,
AuthService,
CollaboratorManagementService,
ToolManagementService,
)
from shared.contracts import AdminPermission, StaffRole, permissions_for_role, role_has_permission, role_includes
bearer_scheme = HTTPBearer(auto_error=False)
@ -45,6 +56,14 @@ def get_audit_log_repository(db: Session = Depends(get_admin_db)) -> AuditLogRep
return AuditLogRepository(db)
def get_tool_draft_repository(db: Session = Depends(get_admin_db)) -> ToolDraftRepository:
return ToolDraftRepository(db)
def get_tool_version_repository(db: Session = Depends(get_admin_db)) -> ToolVersionRepository:
return ToolVersionRepository(db)
def get_audit_service(
repository: AuditLogRepository = Depends(get_audit_log_repository),
) -> AuditService:
@ -77,6 +96,18 @@ def get_collaborator_management_service(
)
def get_tool_management_service(
settings: AdminSettings = Depends(get_settings),
draft_repository: ToolDraftRepository = Depends(get_tool_draft_repository),
version_repository: ToolVersionRepository = Depends(get_tool_version_repository),
) -> ToolManagementService:
return ToolManagementService(
settings=settings,
draft_repository=draft_repository,
version_repository=version_repository,
)
def get_current_staff_context(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
auth_service: AuthService = Depends(get_auth_service),
@ -210,4 +241,3 @@ def get_current_staff_permissions(
current_staff: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal),
) -> tuple[str, ...]:
return tuple(permission.value for permission in permissions_for_role(current_staff.role))

@ -1,6 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from admin_app.api.dependencies import get_settings, require_panel_admin_permission
from admin_app.api.dependencies import (
get_settings,
get_tool_management_service,
require_panel_admin_permission,
)
from admin_app.api.schemas import (
AdminToolContractsResponse,
AdminToolDraftIntakeRequest,
@ -18,21 +22,17 @@ from shared.contracts import AdminPermission
router = APIRouter(prefix="/panel/tools", tags=["panel-tools"])
def _build_service(settings: AdminSettings) -> ToolManagementService:
return ToolManagementService(settings)
@router.get(
"/overview",
response_model=AdminToolOverviewResponse,
)
def panel_tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_overview_payload()
return AdminToolOverviewResponse(
service="orquestrador-admin",
@ -49,12 +49,11 @@ def panel_tools_overview(
response_model=AdminToolContractsResponse,
)
def panel_tool_contracts(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_contracts_payload()
return AdminToolContractsResponse(
service="orquestrador-admin",
@ -72,12 +71,11 @@ def panel_tool_contracts(
response_model=AdminToolDraftListResponse,
)
def panel_tool_drafts(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_drafts_payload()
return AdminToolDraftListResponse(
service="orquestrador-admin",
@ -94,15 +92,15 @@ def panel_tool_drafts(
)
def panel_tool_draft_intake(
draft: AdminToolDraftIntakeRequest,
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
try:
payload = service.preview_draft_submission(
payload = service.create_draft_submission(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
)
except ValueError as exc:
@ -126,12 +124,11 @@ def panel_tool_draft_intake(
response_model=AdminToolReviewQueueResponse,
)
def panel_tool_review_queue(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
service = _build_service(settings)
payload = service.build_review_queue_payload()
return AdminToolReviewQueueResponse(
service="orquestrador-admin",
@ -147,12 +144,11 @@ def panel_tool_review_queue(
response_model=AdminToolPublicationListResponse,
)
def panel_tool_publications(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
service = _build_service(settings)
payload = service.build_publications_payload()
return AdminToolPublicationListResponse(
service="orquestrador-admin",
@ -162,6 +158,7 @@ def panel_tool_publications(
)
def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]:
return [
AdminToolManagementActionResponse(
@ -183,7 +180,7 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct
label="Pre-cadastro web de tool",
href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/drafts/intake"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Valida o formulario real de cadastro diretamente na sessao web do painel.",
description="Valida e persiste o draft diretamente na sessao web do painel.",
),
AdminToolManagementActionResponse(
key="review_queue",
@ -202,6 +199,7 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct
]
def _build_prefixed_path(api_prefix: str, path: str) -> str:
normalized_prefix = api_prefix.rstrip("/")
normalized_path = path if path.startswith("/") else f"/{path}"
@ -210,4 +208,3 @@ def _build_prefixed_path(api_prefix: str, path: str) -> str:
if normalized_path == "/":
return f"{normalized_prefix}/"
return f"{normalized_prefix}{normalized_path}"

@ -1,6 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status
from admin_app.api.dependencies import get_current_staff_principal, get_settings, require_admin_permission
from admin_app.api.dependencies import (
get_settings,
get_tool_management_service,
require_admin_permission,
)
from admin_app.api.schemas import (
AdminToolContractsResponse,
AdminToolDraftIntakeRequest,
@ -18,21 +22,17 @@ from shared.contracts import AdminPermission
router = APIRouter(prefix="/tools", tags=["tools"])
def _build_service(settings: AdminSettings) -> ToolManagementService:
return ToolManagementService(settings)
@router.get(
"/overview",
response_model=AdminToolOverviewResponse,
)
def tools_overview(
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
_: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_overview_payload()
return AdminToolOverviewResponse(
service="orquestrador-admin",
@ -49,12 +49,11 @@ def tools_overview(
response_model=AdminToolContractsResponse,
)
def tool_contracts(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_contracts_payload()
return AdminToolContractsResponse(
service="orquestrador-admin",
@ -72,12 +71,11 @@ def tool_contracts(
response_model=AdminToolDraftListResponse,
)
def tool_drafts(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
payload = service.build_drafts_payload()
return AdminToolDraftListResponse(
service="orquestrador-admin",
@ -94,15 +92,15 @@ def tool_drafts(
)
def tool_draft_intake(
draft: AdminToolDraftIntakeRequest,
settings: AdminSettings = Depends(get_settings),
service: ToolManagementService = Depends(get_tool_management_service),
current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS)
),
):
service = _build_service(settings)
try:
payload = service.preview_draft_submission(
payload = service.create_draft_submission(
draft.model_dump(),
owner_staff_account_id=current_staff.id,
owner_name=current_staff.display_name,
)
except ValueError as exc:
@ -126,12 +124,11 @@ def tool_draft_intake(
response_model=AdminToolReviewQueueResponse,
)
def tool_review_queue(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS)
),
):
service = _build_service(settings)
payload = service.build_review_queue_payload()
return AdminToolReviewQueueResponse(
service="orquestrador-admin",
@ -147,12 +144,11 @@ def tool_review_queue(
response_model=AdminToolPublicationListResponse,
)
def tool_publications(
settings: AdminSettings = Depends(get_settings),
_: AuthenticatedStaffPrincipal = Depends(
service: ToolManagementService = Depends(get_tool_management_service),
_current_staff: AuthenticatedStaffPrincipal = Depends(
require_admin_permission(AdminPermission.PUBLISH_TOOLS)
),
):
service = _build_service(settings)
payload = service.build_publications_payload()
return AdminToolPublicationListResponse(
service="orquestrador-admin",
@ -183,14 +179,14 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes
label="Fila de drafts",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/drafts"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Base do cadastro de novas tools e estados vazios da fase atual.",
description="Lista os drafts administrativos persistidos antes da geracao e revisao.",
),
AdminToolManagementActionResponse(
key="draft_intake",
label="Pre-cadastro de tool",
href=_build_prefixed_path(settings.admin_api_prefix, "/tools/drafts/intake"),
required_permission=AdminPermission.MANAGE_TOOL_DRAFTS,
description="Valida o formulario real de cadastro antes da persistencia definitiva.",
description="Valida e persiste o draft administrativo da nova tool.",
),
AdminToolManagementActionResponse(
key="review_queue",
@ -209,6 +205,7 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes
]
def _build_prefixed_path(api_prefix: str, path: str) -> str:
normalized_prefix = api_prefix.rstrip("/")
normalized_path = path if path.startswith("/") else f"/{path}"

@ -696,6 +696,8 @@ class AdminToolLifecycleStageResponse(BaseModel):
code: ToolLifecycleStatus
label: str
description: str
order: int = Field(ge=1)
terminal: bool = False
class AdminToolParameterTypeResponse(BaseModel):
@ -737,6 +739,8 @@ class AdminToolDraftSummaryResponse(BaseModel):
display_name: str
status: ToolLifecycleStatus
summary: str
current_version_number: int = Field(ge=1)
version_count: int = Field(ge=1)
owner_name: str | None = None
updated_at: datetime | None = None
@ -839,12 +843,15 @@ class AdminToolDraftIntakePreviewParameterResponse(BaseModel):
class AdminToolDraftIntakePreviewResponse(BaseModel):
draft_id: str
version_id: str
tool_name: str
display_name: str
domain: str
status: ToolLifecycleStatus
summary: str
business_goal: str
version_number: int = Field(ge=1)
version_count: int = Field(ge=1)
parameter_count: int
required_parameter_count: int
requires_director_approval: bool

@ -0,0 +1,48 @@
"""
Rotina dedicada de bootstrap do banco administrativo.
Cria tabelas do dominio administrativo de forma explicita, fora do startup do app.
"""
from sqlalchemy import inspect, text
from admin_app.db.database import AdminBase, admin_engine
from admin_app.db.models import AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion
_REGISTERED_MODELS = (AuditLog, StaffAccount, StaffSession, ToolDraft, ToolVersion)
def _ensure_admin_schema_evolution() -> None:
inspector = inspect(admin_engine)
table_names = set(inspector.get_table_names())
if "tool_drafts" in table_names:
tool_draft_columns = {column["name"] for column in inspector.get_columns("tool_drafts")}
statements: list[str] = []
if "current_version_number" not in tool_draft_columns:
statements.append("ALTER TABLE tool_drafts ADD COLUMN current_version_number INT NOT NULL DEFAULT 1")
if "version_count" not in tool_draft_columns:
statements.append("ALTER TABLE tool_drafts ADD COLUMN version_count INT NOT NULL DEFAULT 1")
if statements:
with admin_engine.begin() as connection:
for statement in statements:
connection.execute(text(statement))
def bootstrap_admin_database() -> None:
"""Cria o schema administrativo sem executar seed implicita."""
print("Inicializando schema administrativo...")
try:
AdminBase.metadata.create_all(bind=admin_engine)
_ensure_admin_schema_evolution()
except Exception as exc:
raise RuntimeError(f"Falha ao inicializar banco administrativo: {exc}") from exc
print("Schema administrativo inicializado com sucesso!")
def main() -> None:
bootstrap_admin_database()
if __name__ == "__main__":
main()

@ -0,0 +1,12 @@
"""Alias legado para o bootstrap explicito do banco administrativo."""
from admin_app.db.bootstrap import bootstrap_admin_database
def init_db() -> None:
bootstrap_admin_database()
if __name__ == "__main__":
init_db()

@ -2,5 +2,14 @@ from admin_app.db.models.audit_log import AuditLog
from admin_app.db.models.base import AdminTimestampedModel
from admin_app.db.models.staff_account import StaffAccount
from admin_app.db.models.staff_session import StaffSession
from admin_app.db.models.tool_draft import ToolDraft
from admin_app.db.models.tool_version import ToolVersion
__all__ = ["AdminTimestampedModel", "AuditLog", "StaffAccount", "StaffSession"]
__all__ = [
"AdminTimestampedModel",
"AuditLog",
"StaffAccount",
"StaffSession",
"ToolDraft",
"ToolVersion",
]

@ -0,0 +1,64 @@
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import TypeDecorator
from admin_app.db.models.base import AdminTimestampedModel
from shared.contracts import ToolLifecycleStatus
class ToolLifecycleStatusType(TypeDecorator):
impl = String(32)
cache_ok = True
@property
def python_type(self):
return ToolLifecycleStatus
def process_bind_param(self, value, dialect):
if value is None:
return None
if isinstance(value, ToolLifecycleStatus):
return value.value
return ToolLifecycleStatus(str(value).strip().lower()).value
def process_result_value(self, value, dialect):
if value is None:
return None
return ToolLifecycleStatus(str(value).strip().lower())
class ToolDraft(AdminTimestampedModel):
__tablename__ = "tool_drafts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
draft_id: Mapped[str] = mapped_column(String(40), unique=True, index=True, nullable=False)
tool_name: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
domain: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
status: Mapped[ToolLifecycleStatus] = mapped_column(
ToolLifecycleStatusType(),
nullable=False,
default=ToolLifecycleStatus.DRAFT,
index=True,
)
summary: Mapped[str] = mapped_column(Text, nullable=False)
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
current_version_number: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
version_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
requires_director_approval: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
)
owner_staff_account_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("staff_accounts.id"),
nullable=False,
index=True,
)
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)

@ -0,0 +1,53 @@
from __future__ import annotations
from sqlalchemy import Boolean, ForeignKey, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from admin_app.db.models.base import AdminTimestampedModel
from admin_app.db.models.tool_draft import ToolLifecycleStatusType
from shared.contracts import ToolLifecycleStatus
class ToolVersion(AdminTimestampedModel):
__tablename__ = "tool_versions"
__table_args__ = (
UniqueConstraint(
"tool_name",
"version_number",
name="uq_tool_versions_tool_name_version_number",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
version_id: Mapped[str] = mapped_column(String(120), unique=True, index=True, nullable=False)
draft_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("tool_drafts.id"),
nullable=False,
index=True,
)
tool_name: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[ToolLifecycleStatus] = mapped_column(
ToolLifecycleStatusType(),
nullable=False,
default=ToolLifecycleStatus.DRAFT,
index=True,
)
summary: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
business_goal: Mapped[str] = mapped_column(Text, nullable=False)
parameters_json: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
required_parameter_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
requires_director_approval: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
)
owner_staff_account_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("staff_accounts.id"),
nullable=False,
index=True,
)
owner_display_name: Mapped[str] = mapped_column(String(150), nullable=False)

@ -12,6 +12,8 @@ ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = (
"admin_audit_logs",
"staff_accounts",
"staff_sessions",
"tool_drafts",
"tool_versions",
)

@ -2,10 +2,14 @@ from admin_app.repositories.audit_log_repository import AuditLogRepository
from admin_app.repositories.base_repository import BaseRepository
from admin_app.repositories.staff_account_repository import StaffAccountRepository
from admin_app.repositories.staff_session_repository import StaffSessionRepository
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository
__all__ = [
"AuditLogRepository",
"BaseRepository",
"StaffAccountRepository",
"StaffSessionRepository",
"ToolDraftRepository",
"ToolVersionRepository",
]

@ -0,0 +1,113 @@
from __future__ import annotations
from uuid import uuid4
from sqlalchemy import select
from admin_app.db.models import ToolDraft
from admin_app.repositories.base_repository import BaseRepository
from shared.contracts import ToolLifecycleStatus
class ToolDraftRepository(BaseRepository):
def list_drafts(
self,
*,
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
) -> list[ToolDraft]:
statement = select(ToolDraft).order_by(
ToolDraft.updated_at.desc(),
ToolDraft.created_at.desc(),
)
if statuses:
statement = statement.where(ToolDraft.status.in_(statuses))
return list(self.db.execute(statement).scalars().all())
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
statement = select(ToolDraft).where(ToolDraft.tool_name == str(tool_name or "").strip().lower())
return self.db.execute(statement).scalar_one_or_none()
def create(
self,
*,
tool_name: str,
display_name: str,
domain: str,
description: str,
business_goal: str,
summary: str,
parameters_json: list[dict],
required_parameter_count: int,
current_version_number: int,
version_count: int,
owner_staff_account_id: int,
owner_display_name: str,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolDraft:
draft = ToolDraft(
draft_id=self._build_draft_id(),
tool_name=tool_name,
display_name=display_name,
domain=domain,
description=description,
business_goal=business_goal,
status=ToolLifecycleStatus.DRAFT,
summary=summary,
parameters_json=parameters_json,
required_parameter_count=required_parameter_count,
current_version_number=current_version_number,
version_count=version_count,
requires_director_approval=requires_director_approval,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_display_name,
)
self.db.add(draft)
if commit:
self.db.commit()
self.db.refresh(draft)
else:
self.db.flush()
return draft
def update_submission(
self,
draft: ToolDraft,
*,
display_name: str,
domain: str,
description: str,
business_goal: str,
summary: str,
parameters_json: list[dict],
required_parameter_count: int,
current_version_number: int,
version_count: int,
owner_staff_account_id: int,
owner_display_name: str,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolDraft:
draft.display_name = display_name
draft.domain = domain
draft.description = description
draft.business_goal = business_goal
draft.status = ToolLifecycleStatus.DRAFT
draft.summary = summary
draft.parameters_json = parameters_json
draft.required_parameter_count = required_parameter_count
draft.current_version_number = current_version_number
draft.version_count = version_count
draft.requires_director_approval = requires_director_approval
draft.owner_staff_account_id = owner_staff_account_id
draft.owner_display_name = owner_display_name
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]}"

@ -0,0 +1,81 @@
from __future__ import annotations
from sqlalchemy import func, select
from admin_app.db.models import ToolVersion
from admin_app.repositories.base_repository import BaseRepository
from shared.contracts import ToolLifecycleStatus
class ToolVersionRepository(BaseRepository):
def list_versions(
self,
*,
tool_name: str | None = None,
draft_id: int | None = None,
statuses: tuple[ToolLifecycleStatus, ...] | None = None,
) -> list[ToolVersion]:
statement = select(ToolVersion).order_by(
ToolVersion.version_number.desc(),
ToolVersion.updated_at.desc(),
ToolVersion.created_at.desc(),
)
if tool_name:
statement = statement.where(ToolVersion.tool_name == str(tool_name).strip().lower())
if draft_id is not None:
statement = statement.where(ToolVersion.draft_id == draft_id)
if statuses:
statement = statement.where(ToolVersion.status.in_(statuses))
return list(self.db.execute(statement).scalars().all())
def get_next_version_number(self, tool_name: str) -> int:
statement = select(func.max(ToolVersion.version_number)).where(
ToolVersion.tool_name == str(tool_name or "").strip().lower()
)
max_version = self.db.execute(statement).scalar_one_or_none()
return int(max_version or 0) + 1
def create(
self,
*,
draft_id: int,
tool_name: str,
version_number: int,
summary: str,
description: str,
business_goal: str,
parameters_json: list[dict],
required_parameter_count: int,
owner_staff_account_id: int,
owner_display_name: str,
status: ToolLifecycleStatus = ToolLifecycleStatus.DRAFT,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolVersion:
version = ToolVersion(
version_id=self.build_version_id(tool_name, version_number),
draft_id=draft_id,
tool_name=tool_name,
version_number=version_number,
status=status,
summary=summary,
description=description,
business_goal=business_goal,
parameters_json=parameters_json,
required_parameter_count=required_parameter_count,
requires_director_approval=requires_director_approval,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_display_name,
)
self.db.add(version)
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)}"

@ -5,7 +5,15 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from admin_app.core.settings import AdminSettings
from shared.contracts import ServiceName, ToolLifecycleStatus, ToolParameterType
from admin_app.db.models import ToolDraft, ToolVersion
from admin_app.repositories.tool_draft_repository import ToolDraftRepository
from admin_app.repositories.tool_version_repository import ToolVersionRepository
from shared.contracts import (
ServiceName,
TOOL_LIFECYCLE_STAGES,
ToolLifecycleStatus,
ToolParameterType,
)
@dataclass(frozen=True)
@ -176,15 +184,6 @@ _INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = (
),
)
_LIFECYCLE_DESCRIPTIONS = {
ToolLifecycleStatus.DRAFT: "Estado inicial de uma tool ainda em definicao.",
ToolLifecycleStatus.GENERATED: "Implementacao gerada e pronta para analise tecnica.",
ToolLifecycleStatus.VALIDATED: "Tool validada automaticamente com verificacoes basicas.",
ToolLifecycleStatus.APPROVED: "Versao revisada e aprovada para publicacao controlada.",
ToolLifecycleStatus.ACTIVE: "Tool publicada e apta a abastecer o runtime de produto.",
ToolLifecycleStatus.FAILED: "Falha registrada na geracao, validacao ou ativacao.",
ToolLifecycleStatus.ARCHIVED: "Versao retirada de circulacao e mantida apenas para historico.",
}
_PARAMETER_TYPE_DESCRIPTIONS = {
ToolParameterType.STRING: "Texto livre, codigos e identificadores.",
@ -200,13 +199,26 @@ _PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
class ToolManagementService:
def __init__(self, settings: AdminSettings):
def __init__(
self,
settings: AdminSettings,
draft_repository: ToolDraftRepository | None = None,
version_repository: ToolVersionRepository | None = None,
):
self.settings = settings
self.draft_repository = draft_repository
self.version_repository = version_repository
def build_overview_payload(self) -> dict:
catalog = self.list_publication_catalog()
persisted_draft_count = len(self.draft_repository.list_drafts()) if self.draft_repository else 0
persisted_version_count = 0
if self.version_repository is not None:
persisted_version_count = len(self.version_repository.list_versions())
elif self.draft_repository is not None:
persisted_version_count = sum(draft.version_count for draft in self.draft_repository.list_drafts())
return {
"mode": "bootstrap_catalog",
"mode": "admin_tool_draft_governance",
"metrics": [
{
"key": "active_catalog",
@ -217,7 +229,7 @@ class ToolManagementService:
{
"key": "lifecycle_stages",
"label": "Etapas de lifecycle",
"value": str(len(ToolLifecycleStatus)),
"value": str(len(TOOL_LIFECYCLE_STAGES)),
"description": "Estados compartilhados entre governanca administrativa e publicacao.",
},
{
@ -227,17 +239,23 @@ class ToolManagementService:
"description": "Tipos aceitos pelo contrato inicial de publicacao de tools.",
},
{
"key": "draft_persistence",
"label": "Persistencia de drafts",
"value": "pendente",
"description": "A fase atual entrega uma tela real de cadastro com validacao; a persistencia entra na fase seguinte.",
"key": "persisted_drafts",
"label": "Drafts persistidos",
"value": str(persisted_draft_count),
"description": "Pre-cadastros administrativos ja gravados no armazenamento proprio do admin.",
},
{
"key": "persisted_versions",
"label": "Versoes administrativas",
"value": str(persisted_version_count),
"description": "Historico versionado das iteracoes de cada tool governada pelo admin.",
},
],
"workflow": self.build_lifecycle_payload(),
"next_steps": [
"Criar entidades administrativas para ToolDraft, ToolValidationRun e ToolPublication.",
"Persistir o pre-cadastro validado da nova tela em armazenamento proprio do admin.",
"Persistir artefatos de geracao e validacao por versao sem perder o historico administrativo.",
"Abrir filas de revisao, aprovacao e ativacao com auditoria ponta a ponta.",
"Conectar publicacoes versionadas ao runtime de produto com rollback controlado.",
],
}
@ -301,9 +319,9 @@ class ToolManagementService:
"Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.",
],
"submission_notes": [
"O colaborador pode preencher e validar o pre-cadastro da tool no painel.",
"O colaborador pode preencher, validar e persistir o draft da tool no painel.",
"Toda tool nova segue para revisao e aprovacao de um diretor antes de qualquer publicacao.",
"Nesta fase a tela valida o cadastro e monta o preview, enquanto a persistencia definitiva entra na fase 5.",
"Reenvios da mesma tool reaproveitam o draft raiz e geram uma nova versao administrativa.",
],
"approval_notes": [
"Diretor revisa objetivo, parametros e aderencia ao contrato compartilhado.",
@ -313,12 +331,26 @@ class ToolManagementService:
}
def build_drafts_payload(self) -> dict:
if self.draft_repository is None:
return {
"storage_status": "pending_persistence",
"message": (
"A nova tela de cadastro ja valida o pre-cadastro da tool no painel, mas a persistencia de ToolDraft ainda nao foi conectada neste runtime."
),
"drafts": [],
"supported_statuses": [ToolLifecycleStatus.DRAFT],
}
drafts = self.draft_repository.list_drafts(statuses=(ToolLifecycleStatus.DRAFT,))
message = (
"Nenhum draft administrativo salvo ainda."
if not drafts
else f"{len(drafts)} draft(s) administrativo(s) salvo(s) no admin com historico versionado."
)
return {
"storage_status": "pending_persistence",
"message": (
"A nova tela de cadastro ja valida o pre-cadastro da tool no painel, mas a persistencia de ToolDraft ainda sera criada nas proximas etapas."
),
"drafts": [],
"storage_status": "admin_database",
"message": message,
"drafts": [self._serialize_draft_summary(draft) for draft in drafts],
"supported_statuses": [ToolLifecycleStatus.DRAFT],
}
@ -326,7 +358,7 @@ class ToolManagementService:
return {
"queue_mode": "bootstrap_empty_state",
"message": (
"A fila de revisao ainda opera em estado vazio ate a criacao das entidades de geracao e validacao."
"A fila de revisao ainda opera em estado vazio ate a criacao das entidades de geracao e validacao conectadas as versoes persistidas de cada draft."
),
"items": [],
"supported_statuses": [
@ -344,24 +376,144 @@ class ToolManagementService:
"publications": self.list_publication_catalog(),
}
def create_draft_submission(
self,
payload: dict,
*,
owner_staff_account_id: int | None = None,
owner_name: str | None = None,
) -> dict:
normalized = self._normalize_draft_payload(payload)
warnings = self._build_intake_warnings(normalized)
required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"])
summary = self._build_draft_summary(normalized)
stored_parameters = self._serialize_parameters_for_storage(normalized["parameters"])
if self.draft_repository is None:
version_number = 1
version_count = 1
version_id = self._build_preview_version_id(normalized["tool_name"], version_number)
return {
"storage_status": "validated_preview",
"message": "Pre-cadastro validado no painel. A persistencia definitiva entra na fase de governanca de tools.",
"draft_preview": {
"draft_id": f"preview::{normalized['tool_name']}",
"version_id": version_id,
"tool_name": normalized["tool_name"],
"display_name": normalized["display_name"],
"domain": normalized["domain"],
"status": ToolLifecycleStatus.DRAFT,
"summary": summary,
"business_goal": normalized["business_goal"],
"version_number": version_number,
"version_count": version_count,
"parameter_count": len(normalized["parameters"]),
"required_parameter_count": required_parameter_count,
"requires_director_approval": True,
"owner_name": owner_name,
"parameters": normalized["parameters"],
},
"warnings": warnings,
"next_steps": [
"Persistir o draft administrativo em armazenamento proprio do admin na fase 5.",
"Encaminhar a tool para revisao e aprovacao de um diretor.",
"Executar pipeline de geracao, validacao e publicacao antes da ativacao no produto.",
],
}
if owner_staff_account_id is None:
raise ValueError("owner_staff_account_id e obrigatorio para persistir o draft.")
existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"])
next_version_number = self._resolve_next_version_number(normalized["tool_name"], existing_draft)
next_version_count = next_version_number if existing_draft is None else max(existing_draft.version_count + 1, next_version_number)
if existing_draft is None:
draft = self.draft_repository.create(
tool_name=normalized["tool_name"],
display_name=normalized["display_name"],
domain=normalized["domain"],
description=normalized["description"],
business_goal=normalized["business_goal"],
summary=summary,
parameters_json=stored_parameters,
required_parameter_count=required_parameter_count,
current_version_number=next_version_number,
version_count=next_version_count,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo",
requires_director_approval=True,
)
else:
draft = self.draft_repository.update_submission(
existing_draft,
display_name=normalized["display_name"],
domain=normalized["domain"],
description=normalized["description"],
business_goal=normalized["business_goal"],
summary=summary,
parameters_json=stored_parameters,
required_parameter_count=required_parameter_count,
current_version_number=next_version_number,
version_count=next_version_count,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo",
requires_director_approval=True,
)
version = None
if self.version_repository is not None:
version = self.version_repository.create(
draft_id=draft.id,
tool_name=draft.tool_name,
version_number=next_version_number,
summary=summary,
description=normalized["description"],
business_goal=normalized["business_goal"],
parameters_json=stored_parameters,
required_parameter_count=required_parameter_count,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_name or "Autor administrativo",
status=ToolLifecycleStatus.DRAFT,
requires_director_approval=True,
)
return {
"storage_status": "admin_database",
"message": "Draft administrativo persistido com sucesso em fluxo versionado.",
"draft_preview": self._serialize_draft_preview(draft, version),
"warnings": warnings,
"next_steps": [
f"Encaminhar a versao v{draft.current_version_number} para revisao e aprovacao de um diretor.",
"Conectar a versao persistida ao pipeline de geracao e validacao automatica da tool.",
"Persistir artefatos e publicacoes associados a cada versao governada.",
],
}
def preview_draft_submission(self, payload: dict, *, owner_name: str | None = None) -> dict:
normalized = self._normalize_draft_payload(payload)
warnings = self._build_intake_warnings(normalized)
required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"])
summary = (
f"{normalized['display_name']} pronta para seguir como draft com {len(normalized['parameters'])} parametro(s) e revisao obrigatoria de diretor."
)
summary = self._build_draft_summary(normalized)
existing_draft = None
if self.draft_repository is not None:
existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"])
version_number = self._resolve_next_version_number(normalized["tool_name"], existing_draft)
version_count = version_number if existing_draft is None else max(existing_draft.version_count + 1, version_number)
return {
"storage_status": "validated_preview",
"message": "Pre-cadastro validado no painel. A persistencia definitiva entra na fase de governanca de tools.",
"message": "Pre-cadastro validado no painel com numeracao de versao reservada para a tool.",
"draft_preview": {
"draft_id": 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),
"tool_name": normalized["tool_name"],
"display_name": normalized["display_name"],
"domain": normalized["domain"],
"status": ToolLifecycleStatus.DRAFT,
"summary": summary,
"business_goal": normalized["business_goal"],
"version_number": version_number,
"version_count": version_count,
"parameter_count": len(normalized["parameters"]),
"required_parameter_count": required_parameter_count,
"requires_director_approval": True,
@ -370,8 +522,8 @@ class ToolManagementService:
},
"warnings": warnings,
"next_steps": [
"Persistir o draft administrativo em armazenamento proprio do admin na fase 5.",
"Encaminhar a tool para revisao e aprovacao de um diretor.",
"Persistir a nova versao administrativa para consolidar o historico da tool.",
"Encaminhar a versao para revisao e aprovacao de um diretor.",
"Executar pipeline de geracao, validacao e publicacao antes da ativacao no produto.",
],
}
@ -379,11 +531,13 @@ class ToolManagementService:
def build_lifecycle_payload(self) -> list[dict]:
return [
{
"code": status,
"label": status.value.replace("_", " ").title(),
"description": _LIFECYCLE_DESCRIPTIONS[status],
"code": stage.code,
"label": stage.label,
"description": stage.description,
"order": stage.order,
"terminal": stage.terminal,
}
for status in ToolLifecycleStatus
for stage in TOOL_LIFECYCLE_STAGES
]
def list_publication_catalog(self) -> list[dict]:
@ -406,6 +560,96 @@ class ToolManagementService:
for entry in _BOOTSTRAP_TOOL_CATALOG
]
def _serialize_draft_summary(self, draft: ToolDraft) -> dict:
return {
"draft_id": draft.draft_id,
"tool_name": draft.tool_name,
"display_name": draft.display_name,
"status": draft.status,
"summary": draft.summary,
"current_version_number": draft.current_version_number,
"version_count": draft.version_count,
"owner_name": draft.owner_display_name,
"updated_at": draft.updated_at,
}
def _serialize_draft_preview(
self,
draft: ToolDraft,
version: ToolVersion | None = None,
) -> dict:
parameters = self._serialize_parameters_for_response(draft.parameters_json)
version_id = version.version_id if version is not None else self._build_preview_version_id(
draft.tool_name,
draft.current_version_number,
)
version_number = version.version_number if version is not None else draft.current_version_number
return {
"draft_id": draft.draft_id,
"version_id": version_id,
"tool_name": draft.tool_name,
"display_name": draft.display_name,
"domain": draft.domain,
"status": draft.status,
"summary": draft.summary,
"business_goal": draft.business_goal,
"version_number": version_number,
"version_count": draft.version_count,
"parameter_count": len(parameters),
"required_parameter_count": draft.required_parameter_count,
"requires_director_approval": draft.requires_director_approval,
"owner_name": draft.owner_display_name,
"parameters": parameters,
}
@staticmethod
def _serialize_parameters_for_storage(parameters: list[dict]) -> list[dict]:
return [
{
"name": parameter["name"],
"parameter_type": parameter["parameter_type"].value,
"description": parameter["description"],
"required": parameter["required"],
}
for parameter in parameters
]
@staticmethod
def _serialize_parameters_for_response(parameters_json: list[dict] | None) -> list[dict]:
return [
{
"name": str((parameter or {}).get("name") or "").strip().lower(),
"parameter_type": ToolParameterType(str((parameter or {}).get("parameter_type") or "string").strip().lower()),
"description": str((parameter or {}).get("description") or "").strip(),
"required": bool((parameter or {}).get("required", True)),
}
for parameter in (parameters_json or [])
]
@staticmethod
def _build_draft_summary(payload: dict) -> str:
return (
f"{payload['display_name']} pronta para seguir como draft com {len(payload['parameters'])} parametro(s) e revisao obrigatoria de diretor."
)
@staticmethod
def _build_preview_version_id(tool_name: str, version_number: int) -> str:
return f"tool_version::{str(tool_name or '').strip().lower()}::v{int(version_number)}"
def _resolve_next_version_number(
self,
tool_name: str,
existing_draft: ToolDraft | None,
) -> int:
repository_version = (
self.version_repository.get_next_version_number(tool_name)
if self.version_repository is not None
else 1
)
if existing_draft is None:
return repository_version
return max(repository_version, existing_draft.current_version_number + 1)
def _normalize_draft_payload(self, payload: dict) -> dict:
tool_name = str(payload.get("tool_name") or "").strip().lower()
if not _TOOL_NAME_PATTERN.fullmatch(tool_name):

@ -853,7 +853,7 @@ def render_tool_intake_page(
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Formulario principal</p>
<h3 class="h3 fw-semibold mb-2">Preencher os dados da nova tool</h3>
<p class="text-secondary mb-0">O objetivo aqui e validar estrutura, objetivo operacional e parametros antes da persistencia definitiva.</p>
<p class="text-secondary mb-0">O objetivo aqui e validar estrutura, objetivo operacional e parametros e salvar o draft administrativo antes da revisao.</p>
</div>
</div>

@ -312,7 +312,7 @@ def _build_home_view(
status_variant="success",
highlights=(
"Formulario protegido por sessao web",
"Preview validado antes da persistencia",
"Draft persistido logo apos a validacao",
"Direcao clara para revisao de diretor",
),
cta_label="Abrir cadastro",
@ -938,4 +938,3 @@ def _build_prefixed_path(api_prefix: str, path: str) -> str:
if normalized_path == "/":
return f"{normalized_prefix}/"
return f"{normalized_prefix}{normalized_path}"

@ -386,6 +386,8 @@ function mountToolIntakePage(page) {
<p class="text-secondary mb-3">${escapeHtml(draft?.summary || "")}</p>
<div class="admin-tool-preview-meta small text-secondary mb-3">
<div><strong>Objetivo:</strong> ${escapeHtml(draft?.business_goal || "")}</div>
<div><strong>Versao atual:</strong> v${escapeHtml(String(draft?.version_number || 1))}</div>
<div><strong>Historico:</strong> ${escapeHtml(String(draft?.version_count || 1))} versao(oes)</div>
<div><strong>Parametros:</strong> ${escapeHtml(String(draft?.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>

@ -52,10 +52,14 @@ from shared.contracts.system_functional_configuration import (
from shared.contracts.tool_publication import (
PublishedToolContract,
ServiceName,
TOOL_LIFECYCLE_STAGES,
TOOL_LIFECYCLE_STATUS_SEQUENCE,
ToolLifecycleStageContract,
ToolLifecycleStatus,
ToolParameterContract,
ToolParameterType,
ToolPublicationEnvelope,
get_tool_lifecycle_stage,
)
__all__ = [
@ -68,6 +72,9 @@ __all__ = [
"SYSTEM_FUNCTIONAL_CONFIGURATIONS",
"ServiceName",
"StaffRole",
"TOOL_LIFECYCLE_STAGES",
"TOOL_LIFECYCLE_STATUS_SEQUENCE",
"ToolLifecycleStageContract",
"ToolLifecycleStatus",
"ToolParameterContract",
"ToolParameterType",
@ -100,6 +107,7 @@ __all__ = [
"get_functional_configuration",
"get_model_runtime_contract",
"get_operational_dataset",
"get_tool_lifecycle_stage",
"normalize_staff_role",
"permissions_for_role",
"role_has_permission",

@ -21,6 +21,86 @@ class ToolLifecycleStatus(str, Enum):
ARCHIVED = "archived"
class ToolLifecycleStageContract(BaseModel):
code: ToolLifecycleStatus
label: str
description: str
order: int = Field(ge=1)
terminal: bool = False
TOOL_LIFECYCLE_STAGES: tuple[ToolLifecycleStageContract, ...] = (
ToolLifecycleStageContract(
code=ToolLifecycleStatus.DRAFT,
label="Draft",
description="Estado inicial de uma tool ainda em definicao.",
order=1,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.GENERATED,
label="Generated",
description="Implementacao gerada e pronta para analise tecnica.",
order=2,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.VALIDATED,
label="Validated",
description="Tool validada automaticamente com verificacoes basicas.",
order=3,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.APPROVED,
label="Approved",
description="Versao revisada e aprovada para publicacao controlada.",
order=4,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.ACTIVE,
label="Active",
description="Tool publicada e apta a abastecer o runtime de produto.",
order=5,
terminal=False,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.FAILED,
label="Failed",
description="Falha registrada na geracao, validacao ou ativacao.",
order=6,
terminal=True,
),
ToolLifecycleStageContract(
code=ToolLifecycleStatus.ARCHIVED,
label="Archived",
description="Versao retirada de circulacao e mantida apenas para historico.",
order=7,
terminal=True,
),
)
TOOL_LIFECYCLE_STATUS_SEQUENCE: tuple[ToolLifecycleStatus, ...] = tuple(
stage.code for stage in TOOL_LIFECYCLE_STAGES
)
_TOOL_LIFECYCLE_STAGE_BY_STATUS = {
stage.code: stage for stage in TOOL_LIFECYCLE_STAGES
}
def get_tool_lifecycle_stage(
status: ToolLifecycleStatus | str,
) -> ToolLifecycleStageContract:
normalized_status = (
status
if isinstance(status, ToolLifecycleStatus)
else ToolLifecycleStatus(str(status or "").strip().lower())
)
return _TOOL_LIFECYCLE_STAGE_BY_STATUS[normalized_status]
class ToolParameterType(str, Enum):
STRING = "string"
INTEGER = "integer"

@ -0,0 +1,64 @@
import unittest
from unittest.mock import MagicMock, patch
from admin_app.db import bootstrap as bootstrap_module
from admin_app.db import init_db as init_db_module
class AdminBootstrapRuntimeTests(unittest.TestCase):
@patch.object(bootstrap_module, "_ensure_admin_schema_evolution")
@patch.object(bootstrap_module.AdminBase.metadata, "create_all")
def test_bootstrap_admin_database_creates_schema(self, create_all, ensure_admin_schema_evolution):
bootstrap_module.bootstrap_admin_database()
create_all.assert_called_once_with(bind=bootstrap_module.admin_engine)
ensure_admin_schema_evolution.assert_called_once_with()
@patch.object(
bootstrap_module.AdminBase.metadata,
"create_all",
side_effect=RuntimeError("admin db down"),
)
def test_bootstrap_admin_database_wraps_failures(self, create_all):
with self.assertRaisesRegex(RuntimeError, "admin db down"):
bootstrap_module.bootstrap_admin_database()
create_all.assert_called_once_with(bind=bootstrap_module.admin_engine)
@patch.object(bootstrap_module, "text", side_effect=lambda statement: statement)
@patch.object(bootstrap_module, "inspect")
def test_schema_evolution_adds_version_columns_when_missing(self, inspect_mock, text_mock):
inspector = inspect_mock.return_value
inspector.get_table_names.return_value = ["tool_drafts"]
inspector.get_columns.return_value = [
{"name": "id"},
{"name": "draft_id"},
{"name": "tool_name"},
]
connection = MagicMock()
transaction = MagicMock()
transaction.__enter__.return_value = connection
transaction.__exit__.return_value = None
with patch.object(bootstrap_module.admin_engine, "begin", return_value=transaction) as begin:
bootstrap_module._ensure_admin_schema_evolution()
begin.assert_called_once_with()
executed_statements = [call.args[0] for call in connection.execute.call_args_list]
self.assertEqual(
executed_statements,
[
"ALTER TABLE tool_drafts ADD COLUMN current_version_number INT NOT NULL DEFAULT 1",
"ALTER TABLE tool_drafts ADD COLUMN version_count INT NOT NULL DEFAULT 1",
],
)
@patch.object(init_db_module, "bootstrap_admin_database")
def test_init_db_wrapper_delegates_to_bootstrap_admin_database(self, bootstrap_admin_database):
init_db_module.init_db()
bootstrap_admin_database.assert_called_once_with()
if __name__ == "__main__":
unittest.main()

@ -1,11 +1,116 @@
import unittest
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from admin_app.api.dependencies import get_current_panel_staff_principal
from admin_app.api.dependencies import (
get_current_panel_staff_principal,
get_tool_management_service,
)
from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from shared.contracts import StaffRole
from admin_app.db.models import ToolDraft, ToolVersion
from admin_app.services import ToolManagementService
from shared.contracts import StaffRole, ToolLifecycleStatus
class _FakeToolDraftRepository:
def __init__(self):
self.drafts: list[ToolDraft] = []
self.next_id = 1
def list_drafts(self, *, statuses=None) -> list[ToolDraft]:
drafts = sorted(
self.drafts,
key=lambda draft: draft.updated_at or draft.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if statuses:
allowed = set(statuses)
drafts = [draft for draft in drafts if draft.status in allowed]
return drafts
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
normalized = str(tool_name or "").strip().lower()
for draft in self.drafts:
if draft.tool_name == normalized:
return draft
return None
def create(self, **kwargs) -> ToolDraft:
now = datetime(2026, 3, 31, 17, 0, tzinfo=timezone.utc)
draft = ToolDraft(
id=self.next_id,
draft_id=f"draft_panel_{self.next_id}",
created_at=now,
updated_at=now,
status=ToolLifecycleStatus.DRAFT,
**kwargs,
)
self.next_id += 1
self.drafts.append(draft)
return draft
def update_submission(self, draft: ToolDraft, **kwargs) -> ToolDraft:
draft.display_name = kwargs["display_name"]
draft.domain = kwargs["domain"]
draft.description = kwargs["description"]
draft.business_goal = kwargs["business_goal"]
draft.summary = kwargs["summary"]
draft.parameters_json = kwargs["parameters_json"]
draft.required_parameter_count = kwargs["required_parameter_count"]
draft.current_version_number = kwargs["current_version_number"]
draft.version_count = kwargs["version_count"]
draft.requires_director_approval = kwargs["requires_director_approval"]
draft.owner_staff_account_id = kwargs["owner_staff_account_id"]
draft.owner_display_name = kwargs["owner_display_name"]
draft.updated_at = datetime(2026, 3, 31, 17, draft.current_version_number, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
self.versions: list[ToolVersion] = []
self.next_id = 1
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
reverse=True,
)
if tool_name:
normalized = str(tool_name).strip().lower()
versions = [version for version in versions if version.tool_name == normalized]
if draft_id is not None:
versions = [version for version in versions if version.draft_id == draft_id]
if statuses:
allowed = set(statuses)
versions = [version for version in versions if version.status in allowed]
return versions
def get_next_version_number(self, tool_name: str) -> int:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 18, version_number, tzinfo=timezone.utc)
version = ToolVersion(
id=self.next_id,
version_id=self.build_version_id(kwargs["tool_name"], version_number),
created_at=now,
updated_at=now,
**kwargs,
)
self.next_id += 1
self.versions.append(version)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
return f"tool_version::{normalized}::v{int(version_number)}"
class AdminPanelToolsWebTests(unittest.TestCase):
@ -13,7 +118,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self,
role: StaffRole,
settings: AdminSettings | None = None,
) -> tuple[TestClient, object]:
) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]:
app = create_app(
settings
or AdminSettings(
@ -21,6 +126,13 @@ class AdminPanelToolsWebTests(unittest.TestCase):
admin_api_prefix="/admin",
)
)
draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository()
service = ToolManagementService(
settings=app.state.admin_settings,
draft_repository=draft_repository,
version_repository=version_repository,
)
app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=21,
email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com",
@ -28,10 +140,11 @@ class AdminPanelToolsWebTests(unittest.TestCase):
role=role,
is_active=True,
)
return TestClient(app), app
app.dependency_overrides[get_tool_management_service] = lambda: service
return TestClient(app), app, draft_repository, version_repository
def test_panel_tools_overview_is_available_for_colaborador_session(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/overview")
finally:
@ -39,12 +152,13 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["mode"], "bootstrap_catalog")
self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]])
self.assertIn("/admin/panel/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/panel/tools/drafts/intake", [item["href"] for item in payload["actions"]])
def test_panel_tool_intake_accepts_validated_preview_for_colaborador(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
def test_panel_tool_intake_persists_draft_with_version_metadata_for_colaborador(self):
client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.post(
"/admin/panel/tools/drafts/intake",
@ -75,14 +189,58 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "validated_preview")
self.assertEqual(payload["storage_status"], "admin_database")
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")
self.assertEqual(payload["draft_preview"]["version_number"], 1)
self.assertEqual(payload["draft_preview"]["version_count"], 1)
self.assertTrue(payload["draft_preview"]["requires_director_approval"])
self.assertEqual(len(payload["draft_preview"]["parameters"]), 2)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 1)
def test_panel_drafts_list_returns_single_root_item_after_new_version(self):
client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "revisao",
"tool_name": "consultar_agenda_revisao",
"display_name": "Consultar agenda de revisao",
"description": "Consulta disponibilidade e contexto da agenda de revisao para o time interno.",
"business_goal": "Ajudar a equipe a responder mais rapido sobre slots e horarios disponiveis.",
"parameters": [],
},
)
client.post(
"/admin/panel/tools/drafts/intake",
json={
"domain": "revisao",
"tool_name": "consultar_agenda_revisao",
"display_name": "Consultar agenda de revisao",
"description": "Consulta disponibilidade, contexto e observacoes da agenda de revisao para o time interno.",
"business_goal": "Ajudar a equipe a responder mais rapido sobre slots, horarios e observacoes relevantes.",
"parameters": [],
},
)
response = client.get("/admin/panel/tools/drafts")
finally:
app.dependency_overrides.clear()
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(len(payload["drafts"]), 1)
self.assertEqual(payload["drafts"][0]["tool_name"], "consultar_agenda_revisao")
self.assertEqual(payload["drafts"][0]["current_version_number"], 2)
self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 2)
def test_panel_tools_review_queue_requires_director_session(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/review-queue")
finally:
@ -95,7 +253,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
)
def test_panel_tools_review_queue_is_available_for_diretor_session(self):
client, app = self._build_client_with_role(StaffRole.DIRETOR)
client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/panel/tools/review-queue")
finally:
@ -105,7 +263,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state")
def test_panel_tools_publications_require_director_publication_permission(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/panel/tools/publications")
finally:
@ -118,7 +276,7 @@ class AdminPanelToolsWebTests(unittest.TestCase):
)
def test_panel_tools_publications_return_catalog_for_diretor_session(self):
client, app = self._build_client_with_role(StaffRole.DIRETOR)
client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/panel/tools/publications")
finally:

@ -0,0 +1,42 @@
import unittest
from admin_app.db.models import ToolDraft
from shared.contracts import ToolLifecycleStatus
class ToolDraftModelTests(unittest.TestCase):
def test_tool_draft_declares_expected_table_and_columns(self):
self.assertEqual(ToolDraft.__tablename__, "tool_drafts")
self.assertIn("draft_id", ToolDraft.__table__.columns)
self.assertIn("tool_name", ToolDraft.__table__.columns)
self.assertIn("display_name", ToolDraft.__table__.columns)
self.assertIn("domain", ToolDraft.__table__.columns)
self.assertIn("description", ToolDraft.__table__.columns)
self.assertIn("business_goal", ToolDraft.__table__.columns)
self.assertIn("status", ToolDraft.__table__.columns)
self.assertIn("summary", ToolDraft.__table__.columns)
self.assertIn("parameters_json", ToolDraft.__table__.columns)
self.assertIn("required_parameter_count", ToolDraft.__table__.columns)
self.assertIn("current_version_number", ToolDraft.__table__.columns)
self.assertIn("version_count", ToolDraft.__table__.columns)
self.assertIn("requires_director_approval", ToolDraft.__table__.columns)
self.assertIn("owner_staff_account_id", ToolDraft.__table__.columns)
self.assertIn("owner_display_name", ToolDraft.__table__.columns)
self.assertIn("created_at", ToolDraft.__table__.columns)
self.assertIn("updated_at", ToolDraft.__table__.columns)
def test_tool_draft_uses_unique_tool_name_foreign_key_and_draft_status_default(self):
self.assertTrue(ToolDraft.__table__.columns["tool_name"].unique)
foreign_keys = list(ToolDraft.__table__.columns["owner_staff_account_id"].foreign_keys)
self.assertEqual(len(foreign_keys), 1)
self.assertEqual(str(foreign_keys[0].target_fullname), "staff_accounts.id")
status_column = ToolDraft.__table__.columns["status"]
self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT)
self.assertEqual(status_column.type.process_bind_param("approved", None), "approved")
self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT)
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,310 @@
import unittest
from datetime import datetime, timezone
from admin_app.core import AdminSettings
from admin_app.db.models import ToolDraft, ToolVersion
from admin_app.services.tool_management_service import ToolManagementService
from shared.contracts import ToolLifecycleStatus
class _FakeToolDraftRepository:
def __init__(self):
self.drafts: list[ToolDraft] = []
self.next_id = 1
def list_drafts(self, *, statuses=None) -> list[ToolDraft]:
drafts = sorted(
self.drafts,
key=lambda draft: draft.updated_at or draft.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if statuses:
allowed = set(statuses)
drafts = [draft for draft in drafts if draft.status in allowed]
return drafts
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
normalized = str(tool_name or "").strip().lower()
for draft in self.drafts:
if draft.tool_name == normalized:
return draft
return None
def create(
self,
*,
tool_name: str,
display_name: str,
domain: str,
description: str,
business_goal: str,
summary: str,
parameters_json: list[dict],
required_parameter_count: int,
current_version_number: int,
version_count: int,
owner_staff_account_id: int,
owner_display_name: str,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolDraft:
now = datetime(2026, 3, 31, 15, 0, tzinfo=timezone.utc)
draft = ToolDraft(
id=self.next_id,
draft_id=f"draft_fake_{self.next_id}",
tool_name=tool_name,
display_name=display_name,
domain=domain,
description=description,
business_goal=business_goal,
status=ToolLifecycleStatus.DRAFT,
summary=summary,
parameters_json=parameters_json,
required_parameter_count=required_parameter_count,
current_version_number=current_version_number,
version_count=version_count,
requires_director_approval=requires_director_approval,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_display_name,
created_at=now,
updated_at=now,
)
self.next_id += 1
self.drafts.append(draft)
return draft
def update_submission(
self,
draft: ToolDraft,
*,
display_name: str,
domain: str,
description: str,
business_goal: str,
summary: str,
parameters_json: list[dict],
required_parameter_count: int,
current_version_number: int,
version_count: int,
owner_staff_account_id: int,
owner_display_name: str,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolDraft:
draft.display_name = display_name
draft.domain = domain
draft.description = description
draft.business_goal = business_goal
draft.status = ToolLifecycleStatus.DRAFT
draft.summary = summary
draft.parameters_json = parameters_json
draft.required_parameter_count = required_parameter_count
draft.current_version_number = current_version_number
draft.version_count = version_count
draft.requires_director_approval = requires_director_approval
draft.owner_staff_account_id = owner_staff_account_id
draft.owner_display_name = owner_display_name
draft.updated_at = datetime(2026, 3, 31, 15, current_version_number, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
self.versions: list[ToolVersion] = []
self.next_id = 1
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
reverse=True,
)
if tool_name:
normalized = str(tool_name).strip().lower()
versions = [version for version in versions if version.tool_name == normalized]
if draft_id is not None:
versions = [version for version in versions if version.draft_id == draft_id]
if statuses:
allowed = set(statuses)
versions = [version for version in versions if version.status in allowed]
return versions
def get_next_version_number(self, tool_name: str) -> int:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def create(
self,
*,
draft_id: int,
tool_name: str,
version_number: int,
summary: str,
description: str,
business_goal: str,
parameters_json: list[dict],
required_parameter_count: int,
owner_staff_account_id: int,
owner_display_name: str,
status: ToolLifecycleStatus = ToolLifecycleStatus.DRAFT,
requires_director_approval: bool = True,
commit: bool = True,
) -> ToolVersion:
now = datetime(2026, 3, 31, 16, version_number, tzinfo=timezone.utc)
version = ToolVersion(
id=self.next_id,
version_id=self.build_version_id(tool_name, version_number),
draft_id=draft_id,
tool_name=tool_name,
version_number=version_number,
status=status,
summary=summary,
description=description,
business_goal=business_goal,
parameters_json=parameters_json,
required_parameter_count=required_parameter_count,
requires_director_approval=requires_director_approval,
owner_staff_account_id=owner_staff_account_id,
owner_display_name=owner_display_name,
created_at=now,
updated_at=now,
)
self.next_id += 1
self.versions.append(version)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
return f"tool_version::{normalized}::v{int(version_number)}"
class AdminToolManagementServiceTests(unittest.TestCase):
def setUp(self):
self.draft_repository = _FakeToolDraftRepository()
self.version_repository = _FakeToolVersionRepository()
self.service = ToolManagementService(
settings=AdminSettings(admin_api_prefix="/admin"),
draft_repository=self.draft_repository,
version_repository=self.version_repository,
)
def test_create_draft_submission_persists_initial_tool_version(self):
payload = self.service.create_draft_submission(
{
"domain": "vendas",
"tool_name": "consultar_vendas_periodo",
"display_name": "Consultar vendas por periodo",
"description": "Consulta vendas consolidadas por periodo informado no painel.",
"business_goal": "Ajudar o time interno a acompanhar o desempenho comercial com mais agilidade.",
"parameters": [
{
"name": "periodo_inicio",
"parameter_type": "string",
"description": "Data inicial usada no filtro.",
"required": True,
},
{
"name": "periodo_fim",
"parameter_type": "string",
"description": "Data final usada no filtro.",
"required": True,
},
],
},
owner_staff_account_id=7,
owner_name="Equipe Interna",
)
self.assertEqual(payload["storage_status"], "admin_database")
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)
self.assertEqual(payload["draft_preview"]["version_count"], 1)
self.assertEqual(payload["draft_preview"]["status"], ToolLifecycleStatus.DRAFT)
self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe Interna")
self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 1)
def test_create_draft_submission_reuses_root_draft_and_increments_version(self):
self.service.create_draft_submission(
{
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume o contrato atual de locacao para consulta administrativa.",
"business_goal": "Dar visibilidade rapida ao status do contrato e dos dados principais.",
"parameters": [],
},
owner_staff_account_id=3,
owner_name="Analista de Locacao",
)
payload = self.service.create_draft_submission(
{
"domain": "locacao",
"tool_name": "emitir_resumo_locacao",
"display_name": "Emitir resumo de locacao",
"description": "Resume o contrato atual de locacao e os principais eventos administrativos.",
"business_goal": "Dar visibilidade rapida ao status do contrato, do pagamento e dos dados principais.",
"parameters": [
{
"name": "contrato_id",
"parameter_type": "string",
"description": "Identificador do contrato consultado.",
"required": True,
}
],
},
owner_staff_account_id=4,
owner_name="Coordenacao de Locacao",
)
self.assertEqual(payload["draft_preview"]["version_id"], "tool_version::emitir_resumo_locacao::v2")
self.assertEqual(payload["draft_preview"]["version_number"], 2)
self.assertEqual(payload["draft_preview"]["version_count"], 2)
self.assertEqual(len(self.draft_repository.drafts), 1)
self.assertEqual(len(self.version_repository.versions), 2)
self.assertEqual(self.draft_repository.drafts[0].current_version_number, 2)
self.assertEqual(self.draft_repository.drafts[0].version_count, 2)
self.assertEqual(self.draft_repository.drafts[0].owner_display_name, "Coordenacao de Locacao")
def test_build_drafts_payload_returns_versioned_draft_summaries(self):
self.service.create_draft_submission(
{
"domain": "orquestracao",
"tool_name": "priorizar_contato_quente",
"display_name": "Priorizar contato quente",
"description": "Classifica contatos mais quentes para orientar o proximo passo do atendimento.",
"business_goal": "Dar mais foco comercial ao time interno ao identificar oportunidades mais urgentes.",
"parameters": [],
},
owner_staff_account_id=5,
owner_name="Equipe de Tools",
)
self.service.create_draft_submission(
{
"domain": "orquestracao",
"tool_name": "priorizar_contato_quente",
"display_name": "Priorizar contato quente",
"description": "Classifica contatos mais quentes com sinais adicionais para orientar o atendimento.",
"business_goal": "Dar mais foco comercial ao time interno ao identificar oportunidades quentes com mais contexto.",
"parameters": [],
},
owner_staff_account_id=6,
owner_name="Diretoria Comercial",
)
payload = self.service.build_drafts_payload()
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(len(payload["drafts"]), 1)
self.assertEqual(payload["drafts"][0]["tool_name"], "priorizar_contato_quente")
self.assertEqual(payload["drafts"][0]["current_version_number"], 2)
self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(payload["drafts"][0]["owner_name"], "Diretoria Comercial")
self.assertEqual(payload["supported_statuses"], [ToolLifecycleStatus.DRAFT])
if __name__ == "__main__":
unittest.main()

@ -0,0 +1,43 @@
import unittest
from admin_app.db.models import ToolVersion
from shared.contracts import ToolLifecycleStatus
class ToolVersionModelTests(unittest.TestCase):
def test_tool_version_declares_expected_table_and_columns(self):
self.assertEqual(ToolVersion.__tablename__, "tool_versions")
self.assertIn("version_id", ToolVersion.__table__.columns)
self.assertIn("draft_id", ToolVersion.__table__.columns)
self.assertIn("tool_name", ToolVersion.__table__.columns)
self.assertIn("version_number", ToolVersion.__table__.columns)
self.assertIn("status", ToolVersion.__table__.columns)
self.assertIn("summary", ToolVersion.__table__.columns)
self.assertIn("description", ToolVersion.__table__.columns)
self.assertIn("business_goal", ToolVersion.__table__.columns)
self.assertIn("parameters_json", ToolVersion.__table__.columns)
self.assertIn("required_parameter_count", ToolVersion.__table__.columns)
self.assertIn("requires_director_approval", ToolVersion.__table__.columns)
self.assertIn("owner_staff_account_id", ToolVersion.__table__.columns)
self.assertIn("owner_display_name", ToolVersion.__table__.columns)
def test_tool_version_uses_expected_constraints_and_defaults(self):
foreign_keys = list(ToolVersion.__table__.columns["draft_id"].foreign_keys)
self.assertEqual(len(foreign_keys), 1)
self.assertEqual(str(foreign_keys[0].target_fullname), "tool_drafts.id")
owner_foreign_keys = list(ToolVersion.__table__.columns["owner_staff_account_id"].foreign_keys)
self.assertEqual(len(owner_foreign_keys), 1)
self.assertEqual(str(owner_foreign_keys[0].target_fullname), "staff_accounts.id")
status_column = ToolVersion.__table__.columns["status"]
self.assertEqual(status_column.default.arg, ToolLifecycleStatus.DRAFT)
self.assertEqual(status_column.type.process_bind_param("validated", None), "validated")
self.assertEqual(status_column.type.process_result_value("draft", None), ToolLifecycleStatus.DRAFT)
unique_constraints = {constraint.name for constraint in ToolVersion.__table__.constraints}
self.assertIn("uq_tool_versions_tool_name_version_number", unique_constraints)
if __name__ == "__main__":
unittest.main()

@ -1,11 +1,113 @@
import unittest
from datetime import datetime, timezone
from fastapi.testclient import TestClient
from admin_app.api.dependencies import get_current_staff_principal
from admin_app.api.dependencies import get_current_staff_principal, get_tool_management_service
from admin_app.app_factory import create_app
from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal
from shared.contracts import StaffRole
from admin_app.db.models import ToolDraft, ToolVersion
from admin_app.services import ToolManagementService
from shared.contracts import StaffRole, ToolLifecycleStatus
class _FakeToolDraftRepository:
def __init__(self):
self.drafts: list[ToolDraft] = []
self.next_id = 1
def list_drafts(self, *, statuses=None) -> list[ToolDraft]:
drafts = sorted(
self.drafts,
key=lambda draft: draft.updated_at or draft.created_at or datetime.min.replace(tzinfo=timezone.utc),
reverse=True,
)
if statuses:
allowed = set(statuses)
drafts = [draft for draft in drafts if draft.status in allowed]
return drafts
def get_by_tool_name(self, tool_name: str) -> ToolDraft | None:
normalized = str(tool_name or "").strip().lower()
for draft in self.drafts:
if draft.tool_name == normalized:
return draft
return None
def create(self, **kwargs) -> ToolDraft:
now = datetime(2026, 3, 31, 16, 0, tzinfo=timezone.utc)
draft = ToolDraft(
id=self.next_id,
draft_id=f"draft_api_{self.next_id}",
created_at=now,
updated_at=now,
status=ToolLifecycleStatus.DRAFT,
**kwargs,
)
self.next_id += 1
self.drafts.append(draft)
return draft
def update_submission(self, draft: ToolDraft, **kwargs) -> ToolDraft:
draft.display_name = kwargs["display_name"]
draft.domain = kwargs["domain"]
draft.description = kwargs["description"]
draft.business_goal = kwargs["business_goal"]
draft.summary = kwargs["summary"]
draft.parameters_json = kwargs["parameters_json"]
draft.required_parameter_count = kwargs["required_parameter_count"]
draft.current_version_number = kwargs["current_version_number"]
draft.version_count = kwargs["version_count"]
draft.requires_director_approval = kwargs["requires_director_approval"]
draft.owner_staff_account_id = kwargs["owner_staff_account_id"]
draft.owner_display_name = kwargs["owner_display_name"]
draft.updated_at = datetime(2026, 3, 31, 16, draft.current_version_number, tzinfo=timezone.utc)
return draft
class _FakeToolVersionRepository:
def __init__(self):
self.versions: list[ToolVersion] = []
self.next_id = 1
def list_versions(self, *, tool_name=None, draft_id=None, statuses=None) -> list[ToolVersion]:
versions = sorted(
self.versions,
key=lambda version: (version.version_number, version.updated_at or version.created_at or datetime.min.replace(tzinfo=timezone.utc)),
reverse=True,
)
if tool_name:
normalized = str(tool_name).strip().lower()
versions = [version for version in versions if version.tool_name == normalized]
if draft_id is not None:
versions = [version for version in versions if version.draft_id == draft_id]
if statuses:
allowed = set(statuses)
versions = [version for version in versions if version.status in allowed]
return versions
def get_next_version_number(self, tool_name: str) -> int:
versions = self.list_versions(tool_name=tool_name)
return (versions[0].version_number if versions else 0) + 1
def create(self, **kwargs) -> ToolVersion:
version_number = kwargs["version_number"]
now = datetime(2026, 3, 31, 17, version_number, tzinfo=timezone.utc)
version = ToolVersion(
id=self.next_id,
version_id=self.build_version_id(kwargs["tool_name"], version_number),
created_at=now,
updated_at=now,
**kwargs,
)
self.next_id += 1
self.versions.append(version)
return version
@staticmethod
def build_version_id(tool_name: str, version_number: int) -> str:
normalized = str(tool_name or "").strip().lower()
return f"tool_version::{normalized}::v{int(version_number)}"
class AdminToolsWebTests(unittest.TestCase):
@ -13,7 +115,7 @@ class AdminToolsWebTests(unittest.TestCase):
self,
role: StaffRole,
settings: AdminSettings | None = None,
) -> tuple[TestClient, object]:
) -> tuple[TestClient, object, _FakeToolDraftRepository, _FakeToolVersionRepository]:
app = create_app(
settings
or AdminSettings(
@ -21,6 +123,13 @@ class AdminToolsWebTests(unittest.TestCase):
admin_api_prefix="/admin",
)
)
draft_repository = _FakeToolDraftRepository()
version_repository = _FakeToolVersionRepository()
service = ToolManagementService(
settings=app.state.admin_settings,
draft_repository=draft_repository,
version_repository=version_repository,
)
app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal(
id=11,
email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com",
@ -28,10 +137,11 @@ class AdminToolsWebTests(unittest.TestCase):
role=role,
is_active=True,
)
return TestClient(app), app
app.dependency_overrides[get_tool_management_service] = lambda: service
return TestClient(app), app, draft_repository, version_repository
def test_tools_overview_returns_metrics_workflow_and_actions_for_colaborador(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"})
finally:
@ -40,15 +150,16 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["service"], "orquestrador-admin")
self.assertEqual(payload["mode"], "bootstrap_catalog")
self.assertEqual(payload["mode"], "admin_tool_draft_governance")
self.assertEqual(payload["metrics"][0]["value"], "18")
self.assertIn("persisted_versions", [item["key"] for item in payload["metrics"]])
self.assertIn("active", [item["code"] for item in payload["workflow"]])
self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]])
self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]])
self.assertIn("ToolDraft", payload["next_steps"][0])
self.assertIn("artefatos", payload["next_steps"][0].lower())
def test_tools_contracts_return_shared_contract_snapshot(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"})
finally:
@ -59,24 +170,56 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(payload["publication_source_service"], "admin")
self.assertEqual(payload["publication_target_service"], "product")
self.assertIn("draft", [item["code"] for item in payload["lifecycle_statuses"]])
self.assertEqual(payload["lifecycle_statuses"][0]["order"], 1)
self.assertFalse(payload["lifecycle_statuses"][0]["terminal"])
self.assertTrue(payload["lifecycle_statuses"][-1]["terminal"])
self.assertIn("string", [item["code"] for item in payload["parameter_types"]])
self.assertIn("published_tool", payload["publication_fields"])
def test_tools_drafts_return_empty_state_until_persistence_exists(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
def test_tools_drafts_return_single_root_draft_with_current_version_after_reintake(self):
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
first_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "vendas",
"tool_name": "consultar_resumo_financeiro",
"display_name": "Consultar resumo financeiro",
"description": "Consulta o resumo financeiro consolidado para analise do time administrativo.",
"business_goal": "Ajudar a equipe interna a priorizar a leitura dos principais indicadores financeiros.",
"parameters": [],
},
)
second_response = client.post(
"/admin/tools/drafts/intake",
headers={"Authorization": "Bearer token"},
json={
"domain": "vendas",
"tool_name": "consultar_resumo_financeiro",
"display_name": "Consultar resumo financeiro",
"description": "Consulta o resumo financeiro consolidado com detalhamento adicional para analise administrativa.",
"business_goal": "Ajudar a equipe interna a priorizar indicadores financeiros com contexto extra e leitura mais acionavel.",
"parameters": [],
},
)
response = client.get("/admin/tools/drafts", headers={"Authorization": "Bearer token"})
finally:
app.dependency_overrides.clear()
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "pending_persistence")
self.assertEqual(payload["drafts"], [])
self.assertEqual(payload["storage_status"], "admin_database")
self.assertEqual(len(payload["drafts"]), 1)
self.assertEqual(payload["drafts"][0]["tool_name"], "consultar_resumo_financeiro")
self.assertEqual(payload["drafts"][0]["current_version_number"], 2)
self.assertEqual(payload["drafts"][0]["version_count"], 2)
self.assertEqual(payload["supported_statuses"], ["draft"])
def test_tools_draft_intake_returns_validated_preview(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
def test_tools_draft_intake_persists_admin_draft_with_version_metadata(self):
client, app, draft_repository, version_repository = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.post(
"/admin/tools/drafts/intake",
@ -102,14 +245,20 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["storage_status"], "validated_preview")
self.assertEqual(payload["storage_status"], "admin_database")
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")
self.assertEqual(payload["draft_preview"]["version_number"], 1)
self.assertEqual(payload["draft_preview"]["version_count"], 1)
self.assertTrue(payload["draft_preview"]["requires_director_approval"])
self.assertEqual(payload["draft_preview"]["owner_name"], "Equipe de Tools")
self.assertGreaterEqual(len(payload["warnings"]), 1)
self.assertEqual(len(draft_repository.drafts), 1)
self.assertEqual(len(version_repository.versions), 1)
def test_tools_review_queue_requires_director_review_permission(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally:
@ -122,7 +271,7 @@ class AdminToolsWebTests(unittest.TestCase):
)
def test_tools_review_queue_is_available_for_diretor(self):
client, app = self._build_client_with_role(StaffRole.DIRETOR)
client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"})
finally:
@ -135,7 +284,7 @@ class AdminToolsWebTests(unittest.TestCase):
self.assertIn("validated", payload["supported_statuses"])
def test_tools_publications_require_director_publication_permission(self):
client, app = self._build_client_with_role(StaffRole.COLABORADOR)
client, app, _, _ = self._build_client_with_role(StaffRole.COLABORADOR)
try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally:
@ -148,7 +297,7 @@ class AdminToolsWebTests(unittest.TestCase):
)
def test_tools_publications_return_bootstrap_catalog_for_diretor(self):
client, app = self._build_client_with_role(StaffRole.DIRETOR)
client, app, _, _ = self._build_client_with_role(StaffRole.DIRETOR)
try:
response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"})
finally:

@ -20,7 +20,7 @@ class AdminWriteGovernanceTests(unittest.TestCase):
self.assertEqual(payload["mode"], "admin_internal_tables_only")
self.assertEqual(
payload["allowed_direct_write_tables"],
["admin_audit_logs", "staff_accounts", "staff_sessions"],
["admin_audit_logs", "staff_accounts", "staff_sessions", "tool_drafts", "tool_versions"],
)
self.assertIn("sales_orders", payload["blocked_operational_dataset_keys"])
self.assertIn("orders", payload["blocked_product_source_tables"])
@ -32,6 +32,8 @@ class AdminWriteGovernanceTests(unittest.TestCase):
ensure_direct_admin_write_allowed("staff_accounts")
ensure_direct_admin_write_allowed("staff_sessions")
ensure_direct_admin_write_allowed("admin_audit_logs")
ensure_direct_admin_write_allowed("tool_drafts")
ensure_direct_admin_write_allowed("tool_versions")
def test_unknown_or_product_tables_raise_governance_violation(self):
with self.assertRaises(AdminWriteGovernanceViolation):
@ -42,9 +44,12 @@ class AdminWriteGovernanceTests(unittest.TestCase):
def test_session_guard_accepts_only_internal_admin_tables(self):
enforce_admin_session_write_governance(
new=(_FakeTabledObject("staff_accounts"),),
new=(_FakeTabledObject("staff_accounts"), _FakeTabledObject("tool_versions")),
dirty=(_FakeTabledObject("staff_sessions"),),
deleted=(_FakeTabledObject("admin_audit_logs"),),
deleted=(
_FakeTabledObject("admin_audit_logs"),
_FakeTabledObject("tool_drafts"),
),
)
def test_session_guard_blocks_direct_operational_write_attempt(self):

@ -29,11 +29,14 @@ from shared.contracts import (
ServiceName,
StaffRole,
SYSTEM_FUNCTIONAL_CONFIGURATIONS,
TOOL_LIFECYCLE_STAGES,
TOOL_LIFECYCLE_STATUS_SEQUENCE,
ToolLifecycleStatus,
ToolParameterContract,
ToolParameterType,
ToolPublicationEnvelope,
get_bot_governed_setting,
get_tool_lifecycle_stage,
get_functional_configuration,
get_model_runtime_contract,
get_operational_dataset,
@ -118,6 +121,31 @@ class ToolPublicationContractTests(unittest.TestCase):
ToolParameterType.NUMBER,
)
def test_lifecycle_catalog_exposes_expected_states_in_order(self):
self.assertEqual(
TOOL_LIFECYCLE_STATUS_SEQUENCE,
(
ToolLifecycleStatus.DRAFT,
ToolLifecycleStatus.GENERATED,
ToolLifecycleStatus.VALIDATED,
ToolLifecycleStatus.APPROVED,
ToolLifecycleStatus.ACTIVE,
ToolLifecycleStatus.FAILED,
ToolLifecycleStatus.ARCHIVED,
),
)
self.assertEqual([stage.order for stage in TOOL_LIFECYCLE_STAGES], [1, 2, 3, 4, 5, 6, 7])
def test_get_tool_lifecycle_stage_returns_terminal_metadata(self):
approved_stage = get_tool_lifecycle_stage("approved")
failed_stage = get_tool_lifecycle_stage(ToolLifecycleStatus.FAILED)
archived_stage = get_tool_lifecycle_stage(ToolLifecycleStatus.ARCHIVED)
self.assertEqual(approved_stage.label, "Approved")
self.assertFalse(approved_stage.terminal)
self.assertTrue(failed_stage.terminal)
self.assertTrue(archived_stage.terminal)
class ProductOperationalDataContractTests(unittest.TestCase):
def test_catalog_exposes_expected_operational_domains(self):

Loading…
Cancel
Save