diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index fa70c6e..84205e1 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, @@ -17,7 +18,7 @@ from admin_app.api.schemas import ( ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.services import ToolManagementService -from shared.contracts import AdminPermission +from shared.contracts import AdminPermission, StaffRole, role_has_permission router = APIRouter(prefix="/panel/tools", tags=["panel-tools"]) @@ -29,7 +30,7 @@ router = APIRouter(prefix="/panel/tools", tags=["panel-tools"]) def panel_tools_overview( settings: AdminSettings = Depends(get_settings), service: ToolManagementService = Depends(get_tool_management_service), - _: AuthenticatedStaffPrincipal = Depends( + current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) ), ): @@ -39,7 +40,7 @@ def panel_tools_overview( mode=payload["mode"], metrics=payload["metrics"], workflow=payload["workflow"], - actions=_build_panel_actions(settings), + actions=_build_panel_actions(settings, current_staff.role), next_steps=payload["next_steps"], ) @@ -102,6 +103,7 @@ def panel_tool_draft_intake( draft.model_dump(), owner_staff_account_id=current_staff.id, owner_name=current_staff.display_name, + owner_role=current_staff.role, ) except ValueError as exc: raise HTTPException( @@ -113,6 +115,7 @@ def panel_tool_draft_intake( service="orquestrador-admin", storage_status=payload["storage_status"], message=payload["message"], + submission_policy=payload["submission_policy"], draft_preview=payload["draft_preview"], warnings=payload["warnings"], next_steps=payload["next_steps"], @@ -139,6 +142,62 @@ def panel_tool_review_queue( ) +@router.post( + "/review-queue/{version_id}/review", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_review( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.review_version( + version_id, + reviewer_staff_account_id=current_staff.id, + reviewer_name=current_staff.display_name, + reviewer_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/approve", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_review_queue_approve( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.approve_version( + version_id, + approver_staff_account_id=current_staff.id, + approver_name=current_staff.display_name, + approver_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/publications", response_model=AdminToolPublicationListResponse, @@ -158,9 +217,54 @@ def panel_tool_publications( ) +@router.post( + "/publications/{version_id}/publish", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_publish( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.publish_version( + version_id, + publisher_staff_account_id=current_staff.id, + publisher_name=current_staff.display_name, + publisher_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + -def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]: - return [ + +def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: + return AdminToolGovernanceTransitionResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + queue_entry=payload["queue_entry"], + publication=payload["publication"], + next_steps=payload["next_steps"], + ) + + +def _build_panel_actions( + settings: AdminSettings, + current_role: StaffRole | str | None = None, +) -> list[AdminToolManagementActionResponse]: + actions = [ AdminToolManagementActionResponse( key="overview", label="Overview web de tools", @@ -197,7 +301,9 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct description="Catalogo de tools ativas e prontas para ativacao no produto.", ), ] - + if current_role is None: + return actions + return [action for action in actions if role_has_permission(current_role, action.required_permission)] def _build_prefixed_path(api_prefix: str, path: str) -> str: diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index 40ef92f..f543f12 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -10,6 +10,7 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeRequest, AdminToolDraftIntakeResponse, AdminToolDraftListResponse, + AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, @@ -17,7 +18,7 @@ from admin_app.api.schemas import ( ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal from admin_app.services import ToolManagementService -from shared.contracts import AdminPermission +from shared.contracts import AdminPermission, StaffRole, role_has_permission router = APIRouter(prefix="/tools", tags=["tools"]) @@ -29,7 +30,7 @@ router = APIRouter(prefix="/tools", tags=["tools"]) def tools_overview( settings: AdminSettings = Depends(get_settings), service: ToolManagementService = Depends(get_tool_management_service), - _: AuthenticatedStaffPrincipal = Depends( + current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) ), ): @@ -39,7 +40,7 @@ def tools_overview( mode=payload["mode"], metrics=payload["metrics"], workflow=payload["workflow"], - actions=_build_actions(settings), + actions=_build_actions(settings, current_staff.role), next_steps=payload["next_steps"], ) @@ -102,6 +103,7 @@ def tool_draft_intake( draft.model_dump(), owner_staff_account_id=current_staff.id, owner_name=current_staff.display_name, + owner_role=current_staff.role, ) except ValueError as exc: raise HTTPException( @@ -113,6 +115,7 @@ def tool_draft_intake( service="orquestrador-admin", storage_status=payload["storage_status"], message=payload["message"], + submission_policy=payload["submission_policy"], draft_preview=payload["draft_preview"], warnings=payload["warnings"], next_steps=payload["next_steps"], @@ -139,6 +142,62 @@ def tool_review_queue( ) +@router.post( + "/review-queue/{version_id}/review", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_review( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.review_version( + version_id, + reviewer_staff_account_id=current_staff.id, + reviewer_name=current_staff.display_name, + reviewer_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + +@router.post( + "/review-queue/{version_id}/approve", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_review_queue_approve( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.approve_version( + version_id, + approver_staff_account_id=current_staff.id, + approver_name=current_staff.display_name, + approver_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + @router.get( "/publications", response_model=AdminToolPublicationListResponse, @@ -158,8 +217,54 @@ def tool_publications( ) -def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionResponse]: - return [ +@router.post( + "/publications/{version_id}/publish", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_publish( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.publish_version( + version_id, + publisher_staff_account_id=current_staff.id, + publisher_name=current_staff.display_name, + publisher_role=current_staff.role, + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except PermissionError as exc: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + + return _build_governance_transition_response(payload) + + + +def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceTransitionResponse: + return AdminToolGovernanceTransitionResponse( + service="orquestrador-admin", + message=payload["message"], + version_id=payload["version_id"], + tool_name=payload["tool_name"], + version_number=payload["version_number"], + status=payload["status"], + queue_entry=payload["queue_entry"], + publication=payload["publication"], + next_steps=payload["next_steps"], + ) + + +def _build_actions( + settings: AdminSettings, + current_role: StaffRole | str | None = None, +) -> list[AdminToolManagementActionResponse]: + actions = [ AdminToolManagementActionResponse( key="overview", label="Overview de tools", @@ -203,7 +308,9 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes description="Catalogo bootstrap de tools ativas voltadas ao runtime de produto.", ), ] - + if current_role is None: + return actions + return [action for action in actions if role_has_permission(current_role, action.required_permission)] def _build_prefixed_path(api_prefix: str, path: str) -> str: diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 0bbdf3c..fd27baa 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -755,6 +755,8 @@ class AdminToolDraftListResponse(BaseModel): class AdminToolReviewQueueEntryResponse(BaseModel): entry_id: str + version_id: str + version_number: int = Field(ge=1) tool_name: str display_name: str status: ToolLifecycleStatus @@ -802,6 +804,19 @@ class AdminToolPublicationListResponse(BaseModel): target_service: ServiceName publications: list[AdminToolPublicationSummaryResponse] + +class AdminToolGovernanceTransitionResponse(BaseModel): + service: str + message: str + version_id: str + tool_name: str + version_number: int = Field(ge=1) + status: ToolLifecycleStatus + queue_entry: AdminToolReviewQueueEntryResponse | None = None + publication: AdminToolPublicationSummaryResponse | None = None + next_steps: list[str] + + class AdminToolDraftIntakeParameterRequest(BaseModel): name: str = Field(min_length=1, max_length=64) parameter_type: ToolParameterType @@ -843,6 +858,17 @@ class AdminToolDraftIntakeRequest(BaseModel): return value.strip().lower() +class AdminToolDraftSubmissionPolicyResponse(BaseModel): + mode: str + submitter_role: StaffRole | None = None + submitter_can_publish_now: bool + direct_publication_blocked: bool + requires_director_approval: bool + required_approver_role: StaffRole + required_review_permission: AdminPermission + required_publish_permission: AdminPermission + + class AdminToolDraftIntakePreviewParameterResponse(BaseModel): name: str parameter_type: ToolParameterType @@ -872,6 +898,7 @@ class AdminToolDraftIntakeResponse(BaseModel): service: str storage_status: str message: str + submission_policy: AdminToolDraftSubmissionPolicyResponse draft_preview: AdminToolDraftIntakePreviewResponse warnings: list[str] next_steps: list[str] diff --git a/admin_app/catalogs/__init__.py b/admin_app/catalogs/__init__.py new file mode 100644 index 0000000..277cdee --- /dev/null +++ b/admin_app/catalogs/__init__.py @@ -0,0 +1,13 @@ +from admin_app.catalogs.tool_governance_catalog import ( + BOOTSTRAP_TOOL_CATALOG, + INTAKE_DOMAIN_OPTIONS, + BootstrapToolCatalogEntry, + ToolIntakeDomainOption, +) + +__all__ = [ + "BOOTSTRAP_TOOL_CATALOG", + "INTAKE_DOMAIN_OPTIONS", + "BootstrapToolCatalogEntry", + "ToolIntakeDomainOption", +] \ No newline at end of file diff --git a/admin_app/catalogs/tool_governance_catalog.py b/admin_app/catalogs/tool_governance_catalog.py new file mode 100644 index 0000000..1cdefbe --- /dev/null +++ b/admin_app/catalogs/tool_governance_catalog.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class BootstrapToolCatalogEntry: + tool_name: str + display_name: str + description: str + domain: str + parameter_count: int + + +@dataclass(frozen=True) +class ToolIntakeDomainOption: + value: str + label: str + description: str + + +BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( + BootstrapToolCatalogEntry( + tool_name="consultar_estoque", + display_name="Consultar estoque", + description="Consulta veiculos disponiveis no estoque comercial.", + domain="vendas", + parameter_count=4, + ), + BootstrapToolCatalogEntry( + tool_name="validar_cliente_venda", + display_name="Validar cliente para venda", + description="Avalia elegibilidade de credito para operacoes de venda.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="avaliar_veiculo_troca", + display_name="Avaliar veiculo de troca", + description="Estima o valor de entrada de um veiculo usado.", + domain="vendas", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="agendar_revisao", + display_name="Agendar revisao", + description="Abre um agendamento de revisao ou manutencao.", + domain="revisao", + parameter_count=6, + ), + BootstrapToolCatalogEntry( + tool_name="listar_agendamentos_revisao", + display_name="Listar agendamentos de revisao", + description="Consulta a fila de agendamentos de revisao do cliente.", + domain="revisao", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_agendamento_revisao", + display_name="Cancelar agendamento de revisao", + description="Cancela um agendamento existente por protocolo.", + domain="revisao", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="editar_data_revisao", + display_name="Editar data de revisao", + description="Remarca uma revisao para um novo horario.", + domain="revisao", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="realizar_pedido", + display_name="Realizar pedido", + description="Efetiva um pedido de compra com o veiculo escolhido.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="listar_pedidos", + display_name="Listar pedidos", + description="Consulta pedidos ja abertos pelo cliente.", + domain="vendas", + parameter_count=3, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_pedido", + display_name="Cancelar pedido", + description="Cancela um pedido existente com motivo registrado.", + domain="vendas", + parameter_count=2, + ), + BootstrapToolCatalogEntry( + tool_name="consultar_frota_aluguel", + display_name="Consultar frota de aluguel", + description="Lista veiculos disponiveis para locacao.", + domain="locacao", + parameter_count=6, + ), + BootstrapToolCatalogEntry( + tool_name="abrir_locacao_aluguel", + display_name="Abrir locacao de aluguel", + description="Inicia um contrato de locacao de veiculo.", + domain="locacao", + parameter_count=7, + ), + BootstrapToolCatalogEntry( + tool_name="registrar_devolucao_aluguel", + display_name="Registrar devolucao de aluguel", + description="Fecha uma locacao e devolve o veiculo para a frota.", + domain="locacao", + parameter_count=4, + ), + BootstrapToolCatalogEntry( + tool_name="registrar_pagamento_aluguel", + display_name="Registrar pagamento de aluguel", + description="Registra comprovantes e pagamentos de contratos de locacao.", + domain="locacao", + parameter_count=7, + ), + BootstrapToolCatalogEntry( + tool_name="limpar_contexto_conversa", + display_name="Limpar contexto de conversa", + description="Reinicia o contexto operacional atual do atendimento.", + domain="orquestracao", + parameter_count=1, + ), + BootstrapToolCatalogEntry( + tool_name="continuar_proximo_pedido", + display_name="Continuar proximo pedido", + description="Retoma o proximo pedido pendente do fluxo atual.", + domain="orquestracao", + parameter_count=0, + ), + BootstrapToolCatalogEntry( + tool_name="descartar_pedidos_pendentes", + display_name="Descartar pedidos pendentes", + description="Descarta apenas a fila pendente de pedidos do contexto.", + domain="orquestracao", + parameter_count=1, + ), + BootstrapToolCatalogEntry( + tool_name="cancelar_fluxo_atual", + display_name="Cancelar fluxo atual", + description="Interrompe o fluxo corrente sem apagar todo o contexto.", + domain="orquestracao", + parameter_count=1, + ), +) + +INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = ( + ToolIntakeDomainOption( + value="vendas", + label="Vendas", + description="Ferramentas para estoque, negociacao, pedido e conversao comercial.", + ), + ToolIntakeDomainOption( + value="revisao", + label="Revisao", + description="Ferramentas para agendamento, remarcacao e operacao da oficina.", + ), + ToolIntakeDomainOption( + value="locacao", + label="Locacao", + description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.", + ), + ToolIntakeDomainOption( + value="orquestracao", + label="Orquestracao", + description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.", + ), +) \ No newline at end of file diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py index 79be380..0d8c1b0 100644 --- a/admin_app/db/models/tool_artifact.py +++ b/admin_app/db/models/tool_artifact.py @@ -12,11 +12,15 @@ from admin_app.db.models.base import AdminTimestampedModel class ToolArtifactStage(str, Enum): GENERATION = "generation" VALIDATION = "validation" + GOVERNANCE = "governance" class ToolArtifactKind(str, Enum): GENERATION_REQUEST = "generation_request" VALIDATION_REPORT = "validation_report" + DIRECTOR_REVIEW = "director_review" + DIRECTOR_APPROVAL = "director_approval" + PUBLICATION_RELEASE = "publication_release" class ToolArtifactStorageKind(str, Enum): @@ -111,3 +115,4 @@ class ToolArtifact(AdminTimestampedModel): index=True, ) author_display_name: Mapped[str] = mapped_column(String(150), nullable=False) + diff --git a/admin_app/repositories/tool_draft_repository.py b/admin_app/repositories/tool_draft_repository.py index 40d9277..cf0b130 100644 --- a/admin_app/repositories/tool_draft_repository.py +++ b/admin_app/repositories/tool_draft_repository.py @@ -108,6 +108,22 @@ class ToolDraftRepository(BaseRepository): self.db.flush() return draft + def update_status( + self, + draft: ToolDraft, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolDraft: + draft.status = status + if commit: + self.db.commit() + self.db.refresh(draft) + else: + self.db.flush() + return draft + @staticmethod def _build_draft_id() -> str: return f"draft_{uuid4().hex[:24]}" + diff --git a/admin_app/repositories/tool_metadata_repository.py b/admin_app/repositories/tool_metadata_repository.py index 15b59d0..7c33b6f 100644 --- a/admin_app/repositories/tool_metadata_repository.py +++ b/admin_app/repositories/tool_metadata_repository.py @@ -94,6 +94,21 @@ class ToolMetadataRepository(BaseRepository): self.db.flush() return metadata + def update_status( + self, + metadata: ToolMetadata, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolMetadata: + metadata.status = status + if commit: + self.db.commit() + self.db.refresh(metadata) + else: + self.db.flush() + return metadata + def upsert_version_metadata( self, *, @@ -142,3 +157,4 @@ class ToolMetadataRepository(BaseRepository): def build_metadata_id(tool_name: str, version_number: int) -> str: normalized_tool_name = str(tool_name or "").strip().lower() return f"tool_metadata::{normalized_tool_name}::v{int(version_number)}" + diff --git a/admin_app/repositories/tool_version_repository.py b/admin_app/repositories/tool_version_repository.py index 5e59575..923c8ca 100644 --- a/admin_app/repositories/tool_version_repository.py +++ b/admin_app/repositories/tool_version_repository.py @@ -35,6 +35,12 @@ class ToolVersionRepository(BaseRepository): max_version = self.db.execute(statement).scalar_one_or_none() return int(max_version or 0) + 1 + def get_by_version_id(self, version_id: str) -> ToolVersion | None: + statement = select(ToolVersion).where( + ToolVersion.version_id == str(version_id or "").strip().lower() + ) + return self.db.execute(statement).scalar_one_or_none() + def create( self, *, @@ -75,7 +81,23 @@ class ToolVersionRepository(BaseRepository): self.db.flush() return version + def update_status( + self, + version: ToolVersion, + *, + status: ToolLifecycleStatus, + commit: bool = True, + ) -> ToolVersion: + version.status = status + if commit: + self.db.commit() + self.db.refresh(version) + else: + self.db.flush() + return version + @staticmethod def build_version_id(tool_name: str, version_number: int) -> str: normalized_tool_name = str(tool_name or "").strip().lower() return f"tool_version::{normalized_tool_name}::v{int(version_number)}" + diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index 1f32313..da181b9 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -1,9 +1,11 @@ from __future__ import annotations import re -from dataclasses import dataclass from datetime import UTC, datetime +from sqlalchemy.orm import Session + +from admin_app.catalogs import BOOTSTRAP_TOOL_CATALOG, INTAKE_DOMAIN_OPTIONS from admin_app.core.settings import AdminSettings from admin_app.db.models import ToolDraft, ToolMetadata, ToolVersion from admin_app.db.models.tool_artifact import ( @@ -16,183 +18,18 @@ from admin_app.repositories.tool_draft_repository import ToolDraftRepository from admin_app.repositories.tool_metadata_repository import ToolMetadataRepository from admin_app.repositories.tool_version_repository import ToolVersionRepository from shared.contracts import ( + AdminPermission, GENERATED_TOOL_ENTRYPOINT, GENERATED_TOOLS_PACKAGE, ServiceName, + StaffRole, TOOL_LIFECYCLE_STAGES, ToolLifecycleStatus, ToolParameterType, build_generated_tool_module_name, build_generated_tool_module_path, -) - - -@dataclass(frozen=True) -class BootstrapToolCatalogEntry: - tool_name: str - display_name: str - description: str - domain: str - parameter_count: int - - -@dataclass(frozen=True) -class ToolIntakeDomainOption: - value: str - label: str - description: str - - -_BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( - BootstrapToolCatalogEntry( - tool_name="consultar_estoque", - display_name="Consultar estoque", - description="Consulta veiculos disponiveis no estoque comercial.", - domain="vendas", - parameter_count=4, - ), - BootstrapToolCatalogEntry( - tool_name="validar_cliente_venda", - display_name="Validar cliente para venda", - description="Avalia elegibilidade de credito para operacoes de venda.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="avaliar_veiculo_troca", - display_name="Avaliar veiculo de troca", - description="Estima o valor de entrada de um veiculo usado.", - domain="vendas", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="agendar_revisao", - display_name="Agendar revisao", - description="Abre um agendamento de revisao ou manutencao.", - domain="revisao", - parameter_count=6, - ), - BootstrapToolCatalogEntry( - tool_name="listar_agendamentos_revisao", - display_name="Listar agendamentos de revisao", - description="Consulta a fila de agendamentos de revisao do cliente.", - domain="revisao", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_agendamento_revisao", - display_name="Cancelar agendamento de revisao", - description="Cancela um agendamento existente por protocolo.", - domain="revisao", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="editar_data_revisao", - display_name="Editar data de revisao", - description="Remarca uma revisao para um novo horario.", - domain="revisao", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="realizar_pedido", - display_name="Realizar pedido", - description="Efetiva um pedido de compra com o veiculo escolhido.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="listar_pedidos", - display_name="Listar pedidos", - description="Consulta pedidos ja abertos pelo cliente.", - domain="vendas", - parameter_count=3, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_pedido", - display_name="Cancelar pedido", - description="Cancela um pedido existente com motivo registrado.", - domain="vendas", - parameter_count=2, - ), - BootstrapToolCatalogEntry( - tool_name="consultar_frota_aluguel", - display_name="Consultar frota de aluguel", - description="Lista veiculos disponiveis para locacao.", - domain="locacao", - parameter_count=6, - ), - BootstrapToolCatalogEntry( - tool_name="abrir_locacao_aluguel", - display_name="Abrir locacao de aluguel", - description="Inicia um contrato de locacao de veiculo.", - domain="locacao", - parameter_count=7, - ), - BootstrapToolCatalogEntry( - tool_name="registrar_devolucao_aluguel", - display_name="Registrar devolucao de aluguel", - description="Fecha uma locacao e devolve o veiculo para a frota.", - domain="locacao", - parameter_count=4, - ), - BootstrapToolCatalogEntry( - tool_name="registrar_pagamento_aluguel", - display_name="Registrar pagamento de aluguel", - description="Registra comprovantes e pagamentos de contratos de locacao.", - domain="locacao", - parameter_count=7, - ), - BootstrapToolCatalogEntry( - tool_name="limpar_contexto_conversa", - display_name="Limpar contexto de conversa", - description="Reinicia o contexto operacional atual do atendimento.", - domain="orquestracao", - parameter_count=1, - ), - BootstrapToolCatalogEntry( - tool_name="continuar_proximo_pedido", - display_name="Continuar proximo pedido", - description="Retoma o proximo pedido pendente do fluxo atual.", - domain="orquestracao", - parameter_count=0, - ), - BootstrapToolCatalogEntry( - tool_name="descartar_pedidos_pendentes", - display_name="Descartar pedidos pendentes", - description="Descarta apenas a fila pendente de pedidos do contexto.", - domain="orquestracao", - parameter_count=1, - ), - BootstrapToolCatalogEntry( - tool_name="cancelar_fluxo_atual", - display_name="Cancelar fluxo atual", - description="Interrompe o fluxo corrente sem apagar todo o contexto.", - domain="orquestracao", - parameter_count=1, - ), -) - -_INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = ( - ToolIntakeDomainOption( - value="vendas", - label="Vendas", - description="Ferramentas para estoque, negociacao, pedido e conversao comercial.", - ), - ToolIntakeDomainOption( - value="revisao", - label="Revisao", - description="Ferramentas para agendamento, remarcacao e operacao da oficina.", - ), - ToolIntakeDomainOption( - value="locacao", - label="Locacao", - description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.", - ), - ToolIntakeDomainOption( - value="orquestracao", - label="Orquestracao", - description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.", - ), + normalize_staff_role, + role_has_permission, ) @@ -207,7 +44,15 @@ _PARAMETER_TYPE_DESCRIPTIONS = { _TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") _PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") -_RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in _BOOTSTRAP_TOOL_CATALOG) +_RESERVED_CORE_TOOL_NAMES = frozenset(entry.tool_name for entry in BOOTSTRAP_TOOL_CATALOG) +_PUBLISHED_TOOL_STATUSES = (ToolLifecycleStatus.ACTIVE,) +_REVIEW_QUEUE_STATUSES = ( + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.VALIDATED, + ToolLifecycleStatus.APPROVED, + ToolLifecycleStatus.FAILED, +) class ToolManagementService: @@ -225,6 +70,61 @@ class ToolManagementService: self.metadata_repository = metadata_repository self.artifact_repository = artifact_repository + def _resolve_repository_session(self) -> Session | None: + repository_sessions = [ + repository.db + for repository in ( + self.draft_repository, + self.version_repository, + self.metadata_repository, + self.artifact_repository, + ) + if getattr(repository, "db", None) is not None + ] + + if not repository_sessions: + return None + + primary_session = repository_sessions[0] + for repository_session in repository_sessions[1:]: + if repository_session is not primary_session: + raise RuntimeError("Tool governance repositories must share the same admin database session.") + return primary_session + + @staticmethod + def _commit_repository_session( + repository_session: Session, + *, + draft: ToolDraft, + version: ToolVersion | None = None, + ) -> None: + repository_session.commit() + repository_session.refresh(draft) + if version is not None: + repository_session.refresh(version) + + def _build_submission_policy( + self, + *, + submitter_role: StaffRole | str | None = None, + ) -> dict: + normalized_role = normalize_staff_role(submitter_role) if submitter_role is not None else None + submitter_can_publish_now = ( + role_has_permission(normalized_role, AdminPermission.PUBLISH_TOOLS) + if normalized_role is not None + else False + ) + return { + "mode": "draft_only", + "submitter_role": normalized_role, + "submitter_can_publish_now": submitter_can_publish_now, + "direct_publication_blocked": True, + "requires_director_approval": True, + "required_approver_role": StaffRole.DIRETOR, + "required_review_permission": AdminPermission.REVIEW_TOOL_GENERATIONS, + "required_publish_permission": AdminPermission.PUBLISH_TOOLS, + } + def build_overview_payload(self) -> dict: catalog_payload = self.build_publications_payload() catalog = catalog_payload["publications"] @@ -325,16 +225,27 @@ class ToolManagementService: ], } - def build_draft_form_payload(self) -> dict: + def build_draft_form_payload( + self, + *, + submitter_role: StaffRole | str | None = None, + ) -> dict: + submission_policy = self._build_submission_policy(submitter_role=submitter_role) + submitter_note = ( + "Sua sessao pode cadastrar e salvar o draft, mas nao publica a tool diretamente." + if not submission_policy["submitter_can_publish_now"] + else "Mesmo com permissao de publicacao, este formulario sempre salva a tool primeiro como draft versionado." + ) return { "mode": "validated_preview", + "submission_policy": submission_policy, "domain_options": [ { "value": option.value, "label": option.label, "description": option.description, } - for option in _INTAKE_DOMAIN_OPTIONS + for option in INTAKE_DOMAIN_OPTIONS ], "parameter_types": [ { @@ -352,6 +263,7 @@ class ToolManagementService: ], "submission_notes": [ "O colaborador pode preencher, validar e persistir o draft da tool no painel.", + submitter_note, "Toda tool nova segue para revisao e aprovacao de um diretor antes de qualquer publicacao.", "Reenvios da mesma tool reaproveitam o draft raiz e geram uma nova versao administrativa.", ], @@ -387,36 +299,241 @@ class ToolManagementService: } def build_review_queue_payload(self) -> dict: + queued_versions = self._list_latest_versions(statuses=_REVIEW_QUEUE_STATUSES) + message = ( + "Nenhuma versao aguardando revisao, aprovacao ou publicacao de diretor." + if not queued_versions + else f"{len(queued_versions)} versao(oes) aguardando atuacao de diretor antes da ativacao." + ) return { - "queue_mode": "bootstrap_empty_state", - "message": ( - "A fila de revisao ainda opera em estado vazio ate a criacao das entidades de geracao e validacao conectadas as versoes persistidas de cada draft." - ), - "items": [], - "supported_statuses": [ - ToolLifecycleStatus.GENERATED, - ToolLifecycleStatus.VALIDATED, - ToolLifecycleStatus.APPROVED, - ToolLifecycleStatus.FAILED, - ], + "queue_mode": "governed_admin_queue", + "message": message, + "items": [self._serialize_review_queue_entry(version) for version in queued_versions], + "supported_statuses": list(_REVIEW_QUEUE_STATUSES), } def build_publications_payload(self) -> dict: - metadata_entries = self._list_latest_metadata_entries() - if metadata_entries: + publications_by_tool_name = { + publication["tool_name"]: publication + for publication in self.list_publication_catalog() + } + published_metadata_entries = self._list_latest_metadata_entries( + statuses=_PUBLISHED_TOOL_STATUSES, + ) + if published_metadata_entries: + for metadata in published_metadata_entries: + publications_by_tool_name[metadata.tool_name] = self._serialize_metadata_publication( + metadata + ) return { - "source": "admin_metadata_catalog", + "source": "hybrid_runtime_catalog", "target_service": ServiceName.PRODUCT, - "publications": [ - self._serialize_metadata_publication(metadata) - for metadata in metadata_entries - ], + "publications": list(publications_by_tool_name.values()), } return { "source": "bootstrap_catalog", "target_service": ServiceName.PRODUCT, - "publications": self.list_publication_catalog(), + "publications": list(publications_by_tool_name.values()), + } + + def review_version( + self, + version_id: str, + *, + reviewer_staff_account_id: int, + reviewer_name: str, + reviewer_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.VALIDATED, + allowed_current_statuses=( + ToolLifecycleStatus.DRAFT, + ToolLifecycleStatus.GENERATED, + ), + actor_staff_account_id=reviewer_staff_account_id, + actor_name=reviewer_name, + actor_role=reviewer_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.DIRECTOR_REVIEW, + artifact_summary="Revisao inicial de diretor registrada para a versao governada.", + success_message="Versao revisada por diretor com sucesso e pronta para aprovacao.", + next_steps=[ + "A diretoria ainda precisa aprovar formalmente a versao antes da publicacao.", + "Depois da aprovacao, a publicacao ativa a tool no catalogo governado do produto.", + ], + ) + + def approve_version( + self, + version_id: str, + *, + approver_staff_account_id: int, + approver_name: str, + approver_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.APPROVED, + allowed_current_statuses=(ToolLifecycleStatus.VALIDATED,), + actor_staff_account_id=approver_staff_account_id, + actor_name=approver_name, + actor_role=approver_role, + required_permission=AdminPermission.REVIEW_TOOL_GENERATIONS, + artifact_kind=ToolArtifactKind.DIRECTOR_APPROVAL, + artifact_summary="Aprovacao de diretor registrada para a versao governada.", + success_message="Versao aprovada por diretor com sucesso e pronta para publicacao.", + next_steps=[ + "A publicacao administrativa ainda precisa ser executada antes da ativacao.", + "Enquanto a versao estiver apenas aprovada, ela permanece fora do catalogo ativo do produto.", + ], + ) + + def publish_version( + self, + version_id: str, + *, + publisher_staff_account_id: int, + publisher_name: str, + publisher_role: StaffRole | str, + ) -> dict: + return self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.ACTIVE, + allowed_current_statuses=(ToolLifecycleStatus.APPROVED,), + actor_staff_account_id=publisher_staff_account_id, + actor_name=publisher_name, + actor_role=publisher_role, + required_permission=AdminPermission.PUBLISH_TOOLS, + artifact_kind=ToolArtifactKind.PUBLICATION_RELEASE, + artifact_summary="Publicacao administrativa concluida pela diretoria antes da ativacao.", + success_message="Versao publicada com sucesso e ativada no catalogo governado.", + next_steps=[ + "A versao ativa agora pode ser consumida pelo runtime governado do produto.", + "Se uma nova versao for publicada para a mesma tool, a ativa anterior sera arquivada automaticamente.", + ], + ) + + def _transition_version_status( + self, + version_id: str, + *, + target_status: ToolLifecycleStatus, + allowed_current_statuses: tuple[ToolLifecycleStatus, ...], + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + required_permission: AdminPermission, + artifact_kind: ToolArtifactKind, + artifact_summary: str, + success_message: str, + next_steps: list[str], + ) -> dict: + normalized_role = normalize_staff_role(actor_role) + if not role_has_permission(normalized_role, required_permission): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{required_permission.value}'." + ) + if ( + self.draft_repository is None + or self.version_repository is None + or self.metadata_repository is None + ): + raise RuntimeError( + "Fluxo de governanca de tools ainda nao esta completamente conectado ao armazenamento administrativo." + ) + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + raise LookupError("Versao administrativa nao encontrada.") + + latest_versions_for_tool = self.version_repository.list_versions(tool_name=version.tool_name) + if latest_versions_for_tool and latest_versions_for_tool[0].version_id != version.version_id: + raise ValueError( + "Somente a versao mais recente da tool pode seguir para revisao, aprovacao e publicacao." + ) + if version.status not in allowed_current_statuses: + expected_statuses = ", ".join(status.value for status in allowed_current_statuses) + raise ValueError( + f"A transicao solicitada exige status em ({expected_statuses}), mas a versao esta em '{version.status.value}'." + ) + + draft = self.draft_repository.get_by_tool_name(version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para a versao governada.") + metadata = self.metadata_repository.get_by_tool_version_id(version.id) + if metadata is None: + raise RuntimeError("Metadados persistidos da versao nao encontrados para a governanca administrativa.") + + previous_status = version.status + repository_session = self._resolve_repository_session() + atomic_write_options = {"commit": False} if repository_session is not None else {} + artifact_commit = False if repository_session is not None else None + + try: + if target_status == ToolLifecycleStatus.ACTIVE: + self._archive_active_publications( + tool_name=version.tool_name, + excluding_version_id=version.id, + **atomic_write_options, + ) + + self.version_repository.update_status( + version, + status=target_status, + **atomic_write_options, + ) + self.metadata_repository.update_status( + metadata, + status=target_status, + **atomic_write_options, + ) + self.draft_repository.update_status( + draft, + status=target_status, + **atomic_write_options, + ) + self._persist_governance_artifact( + draft=draft, + version=version, + artifact_kind=artifact_kind, + summary=artifact_summary, + previous_status=previous_status, + current_status=target_status, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + commit=artifact_commit, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + queue_entry = None + publication = None + if target_status == ToolLifecycleStatus.ACTIVE: + publication = self._serialize_metadata_publication(metadata) + else: + queue_entry = self._serialize_review_queue_entry(version) + + return { + "message": success_message, + "version_id": version.version_id, + "tool_name": version.tool_name, + "version_number": version.version_number, + "status": target_status, + "queue_entry": queue_entry, + "publication": publication, + "next_steps": next_steps, } def create_draft_submission( @@ -425,12 +542,14 @@ class ToolManagementService: *, owner_staff_account_id: int | None = None, owner_name: str | None = None, + owner_role: StaffRole | 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"]) + submission_policy = self._build_submission_policy(submitter_role=owner_role) if self.draft_repository is None: version_number = 1 @@ -438,7 +557,8 @@ class ToolManagementService: 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.", + "message": "Pre-cadastro validado no painel sem publicacao direta. A persistencia definitiva entra na fase de governanca de tools.", + "submission_policy": submission_policy, "draft_preview": { "draft_id": f"preview::{normalized['tool_name']}", "version_id": version_id, @@ -467,90 +587,113 @@ class ToolManagementService: if owner_staff_account_id is None: raise ValueError("owner_staff_account_id e obrigatorio para persistir o draft.") + repository_session = self._resolve_repository_session() + atomic_write_options = {"commit": False} if repository_session is not None else {} + artifact_commit = False if repository_session is not None else None + owner_display_name = owner_name or "Autor administrativo" + existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) 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, - ) + try: + 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_display_name, + requires_director_approval=True, + **atomic_write_options, + ) + 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_display_name, + requires_director_approval=True, + **atomic_write_options, + ) - 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, - ) + 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_display_name, + status=ToolLifecycleStatus.DRAFT, + requires_director_approval=True, + **atomic_write_options, + ) - if version is not None and self.metadata_repository is not None: - self.metadata_repository.upsert_version_metadata( - draft_id=draft.id, - tool_version_id=version.id, - tool_name=draft.tool_name, - display_name=draft.display_name, - domain=draft.domain, - description=draft.description, - parameters_json=stored_parameters, - version_number=version.version_number, - status=version.status, - author_staff_account_id=version.owner_staff_account_id, - author_display_name=version.owner_display_name, - ) + if version is not None and self.metadata_repository is not None: + self.metadata_repository.upsert_version_metadata( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=draft.tool_name, + display_name=draft.display_name, + domain=draft.domain, + description=draft.description, + parameters_json=stored_parameters, + version_number=version.version_number, + status=version.status, + author_staff_account_id=version.owner_staff_account_id, + author_display_name=version.owner_display_name, + **atomic_write_options, + ) - if version is not None and self.artifact_repository is not None: - self._persist_initial_version_artifacts( - draft=draft, - version=version, - summary=summary, - warnings=warnings, - stored_parameters=stored_parameters, - required_parameter_count=required_parameter_count, - owner_staff_account_id=owner_staff_account_id, - owner_name=owner_name or "Autor administrativo", - ) + if version is not None and self.artifact_repository is not None: + self._persist_initial_version_artifacts( + draft=draft, + version=version, + summary=summary, + warnings=warnings, + stored_parameters=stored_parameters, + required_parameter_count=required_parameter_count, + owner_staff_account_id=owner_staff_account_id, + owner_name=owner_display_name, + commit=artifact_commit, + ) + + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise return { "storage_status": "admin_database", - "message": "Draft administrativo persistido com sucesso em fluxo versionado.", + "message": "Draft administrativo persistido com sucesso sem publicacao direta, em fluxo versionado e governado.", + "submission_policy": submission_policy, "draft_preview": self._serialize_draft_preview(draft, version), "warnings": warnings, "next_steps": [ @@ -560,11 +703,18 @@ class ToolManagementService: ], } - def preview_draft_submission(self, payload: dict, *, owner_name: str | None = None) -> dict: + def preview_draft_submission( + self, + payload: dict, + *, + owner_name: str | None = None, + owner_role: StaffRole | str | None = None, + ) -> dict: normalized = self._normalize_draft_payload(payload) 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) + submission_policy = self._build_submission_policy(submitter_role=owner_role) existing_draft = None if self.draft_repository is not None: existing_draft = self.draft_repository.get_by_tool_name(normalized["tool_name"]) @@ -572,7 +722,8 @@ class ToolManagementService: version_count = version_number if existing_draft is None else max(existing_draft.version_count + 1, version_number) return { "storage_status": "validated_preview", - "message": "Pre-cadastro validado no painel com numeracao de versao reservada para a tool.", + "message": "Pre-cadastro validado no painel com numeracao de versao reservada para a tool, sem publicacao direta nesta etapa.", + "submission_policy": submission_policy, "draft_preview": { "draft_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), @@ -627,9 +778,106 @@ class ToolManagementService: "published_by": "bootstrap_catalog", "published_at": published_at, } - for entry in _BOOTSTRAP_TOOL_CATALOG + for entry in BOOTSTRAP_TOOL_CATALOG ] + def _archive_active_publications( + self, + *, + tool_name: str, + excluding_version_id: int, + commit: bool = True, + ) -> None: + if self.version_repository is not None: + for active_version in self.version_repository.list_versions( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ACTIVE,), + ): + if active_version.id == excluding_version_id: + continue + self.version_repository.update_status( + active_version, + status=ToolLifecycleStatus.ARCHIVED, + commit=commit, + ) + if self.metadata_repository is not None: + for active_metadata in self.metadata_repository.list_metadata( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ACTIVE,), + ): + if active_metadata.tool_version_id == excluding_version_id: + continue + self.metadata_repository.update_status( + active_metadata, + status=ToolLifecycleStatus.ARCHIVED, + commit=commit, + ) + + def _persist_governance_artifact( + self, + *, + draft: ToolDraft, + version: ToolVersion, + artifact_kind: ToolArtifactKind, + summary: str, + previous_status: ToolLifecycleStatus, + current_status: ToolLifecycleStatus, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole, + commit: bool | None = None, + ) -> None: + if self.artifact_repository is None: + return + + artifact_write_options = {"commit": commit} if commit is not None else {} + self.artifact_repository.upsert_version_artifact( + draft_id=draft.id, + tool_version_id=version.id, + tool_name=version.tool_name, + version_number=version.version_number, + artifact_stage=ToolArtifactStage.GOVERNANCE, + artifact_kind=artifact_kind, + artifact_status=ToolArtifactStatus.SUCCEEDED, + summary=summary, + payload_json=self._build_governance_artifact_payload( + version=version, + artifact_kind=artifact_kind, + previous_status=previous_status, + current_status=current_status, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + ), + author_staff_account_id=actor_staff_account_id, + author_display_name=actor_name, + **artifact_write_options, + ) + + @staticmethod + def _build_governance_artifact_payload( + *, + version: ToolVersion, + artifact_kind: ToolArtifactKind, + previous_status: ToolLifecycleStatus, + current_status: ToolLifecycleStatus, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole, + ) -> dict: + return { + "source": "director_governance", + "action": artifact_kind.value, + "tool_name": version.tool_name, + "version_id": version.version_id, + "version_number": version.version_number, + "previous_status": previous_status.value, + "current_status": current_status.value, + "actor_staff_account_id": actor_staff_account_id, + "actor_display_name": actor_name, + "actor_role": actor_role.value, + } + def _persist_initial_version_artifacts( self, *, @@ -641,10 +889,13 @@ class ToolManagementService: required_parameter_count: int, owner_staff_account_id: int, owner_name: str, + commit: bool | None = None, ) -> None: if self.artifact_repository is None: return + artifact_write_options = {"commit": commit} if commit is not None else {} + generation_payload = self._build_generation_artifact_payload( draft=draft, version=version, @@ -671,6 +922,7 @@ class ToolManagementService: payload_json=generation_payload, author_staff_account_id=owner_staff_account_id, author_display_name=owner_name, + **artifact_write_options, ) self.artifact_repository.upsert_version_artifact( draft_id=draft.id, @@ -684,6 +936,7 @@ class ToolManagementService: payload_json=validation_payload, author_staff_account_id=owner_staff_account_id, author_display_name=owner_name, + **artifact_write_options, ) @staticmethod @@ -743,12 +996,66 @@ class ToolManagementService: ], } - def _list_latest_metadata_entries(self) -> list[ToolMetadata]: + def _list_latest_versions( + self, + *, + statuses: tuple[ToolLifecycleStatus, ...] | None = None, + ) -> list[ToolVersion]: + if self.version_repository is None: + return [] + + latest_by_tool_name: dict[str, ToolVersion | None] = {} + for version in self.version_repository.list_versions(): + normalized_tool_name = str(version.tool_name or "").strip().lower() + if normalized_tool_name in latest_by_tool_name: + continue + if statuses is not None and version.status not in statuses: + latest_by_tool_name[normalized_tool_name] = None + continue + latest_by_tool_name[normalized_tool_name] = version + return [version for version in latest_by_tool_name.values() if version is not None] + + def _serialize_review_queue_entry(self, version: ToolVersion) -> dict: + metadata = ( + self.metadata_repository.get_by_tool_version_id(version.id) + if self.metadata_repository is not None + else None + ) + display_name = metadata.display_name if metadata is not None else version.tool_name.replace("_", " ").title() + return { + "entry_id": version.version_id, + "version_id": version.version_id, + "version_number": version.version_number, + "tool_name": version.tool_name, + "display_name": display_name, + "status": version.status, + "gate": self._build_review_gate(version.status), + "summary": version.summary, + "owner_name": version.owner_display_name, + "queued_at": version.updated_at or version.created_at, + } + + @staticmethod + def _build_review_gate(status: ToolLifecycleStatus) -> str: + gate_by_status = { + ToolLifecycleStatus.DRAFT: "director_review_required", + ToolLifecycleStatus.GENERATED: "validation_confirmation_required", + ToolLifecycleStatus.VALIDATED: "director_approval_required", + ToolLifecycleStatus.APPROVED: "director_publication_required", + ToolLifecycleStatus.FAILED: "revision_required", + } + return gate_by_status.get(status, "governance_required") + + def _list_latest_metadata_entries( + self, + *, + statuses: tuple[ToolLifecycleStatus, ...] | None = None, + ) -> list[ToolMetadata]: if self.metadata_repository is None: return [] latest_by_tool_name: dict[str, ToolMetadata] = {} - for metadata in self.metadata_repository.list_metadata(): + for metadata in self.metadata_repository.list_metadata(statuses=statuses): normalized_tool_name = str(metadata.tool_name or "").strip().lower() if normalized_tool_name in latest_by_tool_name: continue @@ -878,7 +1185,7 @@ class ToolManagementService: raise ValueError("display_name precisa ter pelo menos 4 caracteres.") domain = str(payload.get("domain") or "").strip().lower() - valid_domains = {option.value for option in _INTAKE_DOMAIN_OPTIONS} + valid_domains = {option.value for option in INTAKE_DOMAIN_OPTIONS} if domain not in valid_domains: raise ValueError("Selecione um dominio valido para a nova tool.") diff --git a/admin_app/view/router.py b/admin_app/view/router.py index a24efb8..b376f16 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -89,7 +89,7 @@ def tool_intake_page( return _redirect_to_route(request, "admin_login_view") settings = _resolve_settings(request) - view = _build_tool_intake_view(request, settings) + view = _build_tool_intake_view(request, settings, current_context.principal.role) css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css")) js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js")) return HTMLResponse(render_tool_intake_page(view, css_href=css_href, js_href=js_href)) @@ -661,9 +661,13 @@ def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPa ) -def _build_tool_intake_view(request: Request, settings: AdminSettings) -> AdminToolIntakePageView: +def _build_tool_intake_view( + request: Request, + settings: AdminSettings, + current_role: StaffRole | str | None, +) -> AdminToolIntakePageView: service = ToolManagementService(settings) - form_payload = service.build_draft_form_payload() + form_payload = service.build_draft_form_payload(submitter_role=current_role) return AdminToolIntakePageView( app_name=settings.admin_app_name, diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index ea6f25e..2484102 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -368,6 +368,7 @@ function mountToolIntakePage(page) { function renderDraftPreview(payload) { const draft = payload?.draft_preview; + const submissionPolicy = payload?.submission_policy || null; const warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; const parameters = Array.isArray(draft?.parameters) ? draft.parameters : []; @@ -392,6 +393,9 @@ function mountToolIntakePage(page) {