diff --git a/admin_app/api/dependencies.py b/admin_app/api/dependencies.py
index 04d24db..6ff05a7 100644
--- a/admin_app/api/dependencies.py
+++ b/admin_app/api/dependencies.py
@@ -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))
-
diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py
index 100fb33..fa70c6e 100644
--- a/admin_app/api/routes/panel_tools.py
+++ b/admin_app/api/routes/panel_tools.py
@@ -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}"
-
diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py
index 889cfcd..40ef92f 100644
--- a/admin_app/api/routes/tools.py
+++ b/admin_app/api/routes/tools.py
@@ -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}"
diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py
index b18e663..0c5a554 100644
--- a/admin_app/api/schemas.py
+++ b/admin_app/api/schemas.py
@@ -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
diff --git a/admin_app/db/bootstrap.py b/admin_app/db/bootstrap.py
new file mode 100644
index 0000000..3077fb3
--- /dev/null
+++ b/admin_app/db/bootstrap.py
@@ -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()
diff --git a/admin_app/db/init_db.py b/admin_app/db/init_db.py
new file mode 100644
index 0000000..3ec53c6
--- /dev/null
+++ b/admin_app/db/init_db.py
@@ -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()
diff --git a/admin_app/db/models/__init__.py b/admin_app/db/models/__init__.py
index 2746396..261e021 100644
--- a/admin_app/db/models/__init__.py
+++ b/admin_app/db/models/__init__.py
@@ -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",
+]
diff --git a/admin_app/db/models/tool_draft.py b/admin_app/db/models/tool_draft.py
new file mode 100644
index 0000000..0bd4d30
--- /dev/null
+++ b/admin_app/db/models/tool_draft.py
@@ -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)
diff --git a/admin_app/db/models/tool_version.py b/admin_app/db/models/tool_version.py
new file mode 100644
index 0000000..21dd293
--- /dev/null
+++ b/admin_app/db/models/tool_version.py
@@ -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)
diff --git a/admin_app/db/write_governance.py b/admin_app/db/write_governance.py
index 8ecd773..71f452f 100644
--- a/admin_app/db/write_governance.py
+++ b/admin_app/db/write_governance.py
@@ -12,6 +12,8 @@ ALLOWED_ADMIN_WRITE_TABLES: tuple[str, ...] = (
"admin_audit_logs",
"staff_accounts",
"staff_sessions",
+ "tool_drafts",
+ "tool_versions",
)
diff --git a/admin_app/repositories/__init__.py b/admin_app/repositories/__init__.py
index 82290b4..391c4ca 100644
--- a/admin_app/repositories/__init__.py
+++ b/admin_app/repositories/__init__.py
@@ -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",
]
diff --git a/admin_app/repositories/tool_draft_repository.py b/admin_app/repositories/tool_draft_repository.py
new file mode 100644
index 0000000..40d9277
--- /dev/null
+++ b/admin_app/repositories/tool_draft_repository.py
@@ -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]}"
diff --git a/admin_app/repositories/tool_version_repository.py b/admin_app/repositories/tool_version_repository.py
new file mode 100644
index 0000000..5e59575
--- /dev/null
+++ b/admin_app/repositories/tool_version_repository.py
@@ -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)}"
diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py
index b8c0cb1..5381760 100644
--- a/admin_app/services/tool_management_service.py
+++ b/admin_app/services/tool_management_service.py
@@ -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):
diff --git a/admin_app/view/rendering.py b/admin_app/view/rendering.py
index 32f2927..be73971 100644
--- a/admin_app/view/rendering.py
+++ b/admin_app/view/rendering.py
@@ -853,7 +853,7 @@ def render_tool_intake_page(
Formulario principal
Preencher os dados da nova tool
-
O objetivo aqui e validar estrutura, objetivo operacional e parametros antes da persistencia definitiva.
+
O objetivo aqui e validar estrutura, objetivo operacional e parametros e salvar o draft administrativo antes da revisao.
diff --git a/admin_app/view/router.py b/admin_app/view/router.py
index 7c1669b..a24efb8 100644
--- a/admin_app/view/router.py
+++ b/admin_app/view/router.py
@@ -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}"
-
diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js
index 334c5e4..38e7ac0 100644
--- a/admin_app/view/static/scripts/panel.js
+++ b/admin_app/view/static/scripts/panel.js
@@ -386,6 +386,8 @@ function mountToolIntakePage(page) {
${escapeHtml(draft?.summary || "")}
Objetivo: ${escapeHtml(draft?.business_goal || "")}
+
Versao atual: v${escapeHtml(String(draft?.version_number || 1))}
+
Historico: ${escapeHtml(String(draft?.version_count || 1))} versao(oes)
Parametros: ${escapeHtml(String(draft?.parameter_count || 0))}
Obrigatorios: ${escapeHtml(String(draft?.required_parameter_count || 0))}
Aprovacao: ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}
diff --git a/shared/contracts/__init__.py b/shared/contracts/__init__.py
index 013c1a5..7dd3369 100644
--- a/shared/contracts/__init__.py
+++ b/shared/contracts/__init__.py
@@ -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",
diff --git a/shared/contracts/tool_publication.py b/shared/contracts/tool_publication.py
index 31214a2..9bb0514 100644
--- a/shared/contracts/tool_publication.py
+++ b/shared/contracts/tool_publication.py
@@ -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"
diff --git a/tests/test_admin_db_bootstrap.py b/tests/test_admin_db_bootstrap.py
new file mode 100644
index 0000000..8763b2d
--- /dev/null
+++ b/tests/test_admin_db_bootstrap.py
@@ -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()
diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py
index d4cbe5d..5d1d25b 100644
--- a/tests/test_admin_panel_tools_web.py
+++ b/tests/test_admin_panel_tools_web.py
@@ -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:
diff --git a/tests/test_admin_tool_draft_model.py b/tests/test_admin_tool_draft_model.py
new file mode 100644
index 0000000..6c7c62f
--- /dev/null
+++ b/tests/test_admin_tool_draft_model.py
@@ -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()
diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py
new file mode 100644
index 0000000..0273d91
--- /dev/null
+++ b/tests/test_admin_tool_management_service.py
@@ -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()
diff --git a/tests/test_admin_tool_version_model.py b/tests/test_admin_tool_version_model.py
new file mode 100644
index 0000000..38f7dfc
--- /dev/null
+++ b/tests/test_admin_tool_version_model.py
@@ -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()
diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py
index 6e09dd6..f738f13 100644
--- a/tests/test_admin_tools_web.py
+++ b/tests/test_admin_tools_web.py
@@ -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:
diff --git a/tests/test_admin_write_governance.py b/tests/test_admin_write_governance.py
index c3ff34c..cd74d6a 100644
--- a/tests/test_admin_write_governance.py
+++ b/tests/test_admin_write_governance.py
@@ -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):
diff --git a/tests/test_shared_contracts.py b/tests/test_shared_contracts.py
index f98a862..7a8364a 100644
--- a/tests/test_shared_contracts.py
+++ b/tests/test_shared_contracts.py
@@ -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):