From b3662906bc0e48bee416ba0cb3d96130be1f8b4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Tue, 31 Mar 2026 12:18:35 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20iniciar=20governanca?= =?UTF-8?q?=20versionada=20de=20tools=20na=20fase=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_app/api/dependencies.py | 36 +- admin_app/api/routes/panel_tools.py | 43 ++- admin_app/api/routes/tools.py | 43 ++- admin_app/api/schemas.py | 7 + admin_app/db/bootstrap.py | 48 +++ admin_app/db/init_db.py | 12 + admin_app/db/models/__init__.py | 11 +- admin_app/db/models/tool_draft.py | 64 ++++ admin_app/db/models/tool_version.py | 53 +++ admin_app/db/write_governance.py | 2 + admin_app/repositories/__init__.py | 4 + .../repositories/tool_draft_repository.py | 113 +++++++ .../repositories/tool_version_repository.py | 81 +++++ admin_app/services/tool_management_service.py | 320 +++++++++++++++--- admin_app/view/rendering.py | 2 +- admin_app/view/router.py | 3 +- admin_app/view/static/scripts/panel.js | 2 + shared/contracts/__init__.py | 8 + shared/contracts/tool_publication.py | 80 +++++ tests/test_admin_db_bootstrap.py | 64 ++++ tests/test_admin_panel_tools_web.py | 184 +++++++++- tests/test_admin_tool_draft_model.py | 42 +++ tests/test_admin_tool_management_service.py | 310 +++++++++++++++++ tests/test_admin_tool_version_model.py | 43 +++ tests/test_admin_tools_web.py | 187 ++++++++-- tests/test_admin_write_governance.py | 11 +- tests/test_shared_contracts.py | 28 ++ 27 files changed, 1675 insertions(+), 126 deletions(-) create mode 100644 admin_app/db/bootstrap.py create mode 100644 admin_app/db/init_db.py create mode 100644 admin_app/db/models/tool_draft.py create mode 100644 admin_app/db/models/tool_version.py create mode 100644 admin_app/repositories/tool_draft_repository.py create mode 100644 admin_app/repositories/tool_version_repository.py create mode 100644 tests/test_admin_db_bootstrap.py create mode 100644 tests/test_admin_tool_draft_model.py create mode 100644 tests/test_admin_tool_management_service.py create mode 100644 tests/test_admin_tool_version_model.py 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):