From de455b8566c59878a5aef6a0059e79accc75200a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vitor=20Hugo=20Belorio=20Sim=C3=A3o?= Date: Thu, 2 Apr 2026 09:30:35 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(admin):=20concluir=20pipeline?= =?UTF-8?q?=20governada=20de=20tools=20na=20fase=206?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin_app/api/routes/panel_tools.py | 113 ++++ admin_app/api/routes/tools.py | 113 ++++ admin_app/api/schemas.py | 74 +++ admin_app/db/models/tool_artifact.py | 2 + admin_app/services/tool_management_service.py | 496 +++++++++++++++++- admin_app/view/rendering.py | 82 +++ admin_app/view/router.py | 2 +- admin_app/view/static/scripts/panel.js | 457 +++++++++++++++- tests/test_admin_panel_tools_web.py | 160 +++++- tests/test_admin_tool_management_service.py | 236 ++++++++- tests/test_admin_tools_web.py | 174 ++++++ tests/test_admin_view_bootstrap.py | 4 + 12 files changed, 1889 insertions(+), 24 deletions(-) diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index 33a6f96..dd46bad 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -11,10 +11,13 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolGenerationPipelineResponse, + AdminToolGovernanceDecisionRequest, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, + AdminToolReviewDecisionRequest, + AdminToolReviewDetailResponse, AdminToolReviewQueueResponse, ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal @@ -171,12 +174,32 @@ def panel_tool_review_queue( ) +@router.get( + "/review-queue/{version_id}", + response_model=AdminToolReviewDetailResponse, +) +def panel_tool_review_queue_detail( + 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.build_review_detail_payload(version_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return _build_review_detail_response(payload) + + @router.post( "/review-queue/{version_id}/review", response_model=AdminToolGovernanceTransitionResponse, ) def panel_tool_review_queue_review( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -188,6 +211,8 @@ def panel_tool_review_queue_review( reviewer_staff_account_id=current_staff.id, reviewer_name=current_staff.display_name, reviewer_role=current_staff.role, + decision_notes=decision.decision_notes, + reviewed_generated_code=decision.reviewed_generated_code, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -205,6 +230,7 @@ def panel_tool_review_queue_review( ) def panel_tool_review_queue_approve( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_panel_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -216,6 +242,7 @@ def panel_tool_review_queue_approve( approver_staff_account_id=current_staff.id, approver_name=current_staff.display_name, approver_role=current_staff.role, + decision_notes=decision.decision_notes, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -274,6 +301,66 @@ def panel_tool_publications_publish( return _build_governance_transition_response(payload) +@router.post( + "/publications/{version_id}/deactivate", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_deactivate( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.deactivate_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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( + "/publications/{version_id}/rollback", + response_model=AdminToolGovernanceTransitionResponse, +) +def panel_tool_publications_rollback( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.rollback_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: return AdminToolGenerationPipelineResponse( @@ -305,6 +392,32 @@ def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceT ) +def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse: + return AdminToolReviewDetailResponse( + service="orquestrador-admin", + version_id=payload["version_id"], + tool_name=payload["tool_name"], + display_name=payload["display_name"], + domain=payload["domain"], + version_number=payload["version_number"], + status=payload["status"], + summary=payload["summary"], + description=payload["description"], + business_goal=payload["business_goal"], + owner_name=payload["owner_name"], + parameters=payload["parameters"], + queue_entry=payload["queue_entry"], + automated_validations=payload["automated_validations"], + automated_validation_summary=payload["automated_validation_summary"], + generated_module=payload["generated_module"], + generated_callable=payload["generated_callable"], + generated_source_code=payload["generated_source_code"], + human_gate=payload["human_gate"], + decision_history=payload["decision_history"], + next_steps=payload["next_steps"], + ) + + def _build_panel_actions( settings: AdminSettings, current_role: StaffRole | str | None = None, diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index f57e69c..95fa74b 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -11,10 +11,13 @@ from admin_app.api.schemas import ( AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolGenerationPipelineResponse, + AdminToolGovernanceDecisionRequest, AdminToolGovernanceTransitionResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, AdminToolPublicationListResponse, + AdminToolReviewDecisionRequest, + AdminToolReviewDetailResponse, AdminToolReviewQueueResponse, ) from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal @@ -171,12 +174,32 @@ def tool_review_queue( ) +@router.get( + "/review-queue/{version_id}", + response_model=AdminToolReviewDetailResponse, +) +def tool_review_queue_detail( + version_id: str, + service: ToolManagementService = Depends(get_tool_management_service), + _current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) + ), +): + try: + payload = service.build_review_detail_payload(version_id) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + return _build_review_detail_response(payload) + + @router.post( "/review-queue/{version_id}/review", response_model=AdminToolGovernanceTransitionResponse, ) def tool_review_queue_review( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -188,6 +211,8 @@ def tool_review_queue_review( reviewer_staff_account_id=current_staff.id, reviewer_name=current_staff.display_name, reviewer_role=current_staff.role, + decision_notes=decision.decision_notes, + reviewed_generated_code=decision.reviewed_generated_code, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -205,6 +230,7 @@ def tool_review_queue_review( ) def tool_review_queue_approve( version_id: str, + decision: AdminToolReviewDecisionRequest, service: ToolManagementService = Depends(get_tool_management_service), current_staff: AuthenticatedStaffPrincipal = Depends( require_admin_permission(AdminPermission.REVIEW_TOOL_GENERATIONS) @@ -216,6 +242,7 @@ def tool_review_queue_approve( approver_staff_account_id=current_staff.id, approver_name=current_staff.display_name, approver_role=current_staff.role, + decision_notes=decision.decision_notes, ) except LookupError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -274,6 +301,66 @@ def tool_publications_publish( return _build_governance_transition_response(payload) +@router.post( + "/publications/{version_id}/deactivate", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_deactivate( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.deactivate_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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( + "/publications/{version_id}/rollback", + response_model=AdminToolGovernanceTransitionResponse, +) +def tool_publications_rollback( + version_id: str, + decision: AdminToolGovernanceDecisionRequest, + service: ToolManagementService = Depends(get_tool_management_service), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.PUBLISH_TOOLS) + ), +): + try: + payload = service.rollback_version( + version_id, + actor_staff_account_id=current_staff.id, + actor_name=current_staff.display_name, + actor_role=current_staff.role, + decision_notes=decision.decision_notes, + ) + 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_pipeline_response(payload: dict) -> AdminToolGenerationPipelineResponse: return AdminToolGenerationPipelineResponse( @@ -305,6 +392,32 @@ def _build_governance_transition_response(payload: dict) -> AdminToolGovernanceT ) +def _build_review_detail_response(payload: dict) -> AdminToolReviewDetailResponse: + return AdminToolReviewDetailResponse( + service="orquestrador-admin", + version_id=payload["version_id"], + tool_name=payload["tool_name"], + display_name=payload["display_name"], + domain=payload["domain"], + version_number=payload["version_number"], + status=payload["status"], + summary=payload["summary"], + description=payload["description"], + business_goal=payload["business_goal"], + owner_name=payload["owner_name"], + parameters=payload["parameters"], + queue_entry=payload["queue_entry"], + automated_validations=payload["automated_validations"], + automated_validation_summary=payload["automated_validation_summary"], + generated_module=payload["generated_module"], + generated_callable=payload["generated_callable"], + generated_source_code=payload["generated_source_code"], + human_gate=payload["human_gate"], + decision_history=payload["decision_history"], + next_steps=payload["next_steps"], + ) + + def _build_actions( settings: AdminSettings, current_role: StaffRole | str | None = None, diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index 3b262bf..0d4f00f 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -776,6 +776,75 @@ class AdminToolReviewQueueResponse(BaseModel): supported_statuses: list[ToolLifecycleStatus] +class AdminToolReviewDecisionRequest(BaseModel): + decision_notes: str = Field(min_length=12, max_length=2000) + reviewed_generated_code: bool = False + + @field_validator("decision_notes") + @classmethod + def normalize_decision_notes(cls, value: str) -> str: + return value.strip() + + +class AdminToolGovernanceDecisionRequest(BaseModel): + decision_notes: str = Field(min_length=12, max_length=2000) + + @field_validator("decision_notes") + @classmethod + def normalize_decision_notes(cls, value: str) -> str: + return value.strip() + + +class AdminToolReviewHumanGateResponse(BaseModel): + current_gate: str + review_action_available: bool + approval_action_available: bool + publication_action_available: bool + deactivation_action_available: bool + rollback_action_available: bool + rollback_target_version_id: str | None = None + rollback_target_version_number: int | None = None + requires_decision_notes: bool + requires_code_review_confirmation: bool + + +class AdminToolReviewHistoryEntryResponse(BaseModel): + action_key: str + label: str + summary: str + previous_status: str | None = None + current_status: str | None = None + actor_name: str | None = None + actor_role: str | None = None + decision_notes: str | None = None + reviewed_generated_code: bool | None = None + recorded_at: datetime | None = None + + +class AdminToolReviewDetailResponse(BaseModel): + service: str + version_id: str + tool_name: str + display_name: str + domain: str + version_number: int = Field(ge=1) + status: ToolLifecycleStatus + summary: str + description: str + business_goal: str + owner_name: str | None = None + parameters: list["AdminToolPublicationParameterResponse"] = Field(default_factory=list) + queue_entry: AdminToolReviewQueueEntryResponse + automated_validations: list["AdminToolAutomatedValidationResponse"] = Field(default_factory=list) + automated_validation_summary: str | None = None + generated_module: str + generated_callable: str + generated_source_code: str + human_gate: AdminToolReviewHumanGateResponse + decision_history: list[AdminToolReviewHistoryEntryResponse] = Field(default_factory=list) + next_steps: list[str] = Field(default_factory=list) + + class AdminToolPublicationParameterResponse(BaseModel): name: str parameter_type: ToolParameterType @@ -791,6 +860,7 @@ class AdminToolPublicationSummaryResponse(BaseModel): domain: str version: int status: ToolLifecycleStatus + version_id: str | None = None parameter_count: int parameters: list[AdminToolPublicationParameterResponse] = Field(default_factory=list) author_name: str | None = None @@ -798,6 +868,10 @@ class AdminToolPublicationSummaryResponse(BaseModel): implementation_callable: str published_by: str | None = None published_at: datetime | None = None + deactivation_action_available: bool = False + rollback_action_available: bool = False + rollback_target_version_id: str | None = None + rollback_target_version_number: int | None = None class AdminToolPublicationListResponse(BaseModel): diff --git a/admin_app/db/models/tool_artifact.py b/admin_app/db/models/tool_artifact.py index 0d8c1b0..465a427 100644 --- a/admin_app/db/models/tool_artifact.py +++ b/admin_app/db/models/tool_artifact.py @@ -21,6 +21,8 @@ class ToolArtifactKind(str, Enum): DIRECTOR_REVIEW = "director_review" DIRECTOR_APPROVAL = "director_approval" PUBLICATION_RELEASE = "publication_release" + PUBLICATION_DEACTIVATION = "publication_deactivation" + PUBLICATION_ROLLBACK = "publication_rollback" class ToolArtifactStorageKind(str, Enum): diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index aa658c8..189f285 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -97,6 +97,7 @@ _REVIEW_QUEUE_STATUSES = ( ToolLifecycleStatus.APPROVED, ToolLifecycleStatus.FAILED, ) +_HUMAN_DECISION_NOTES_MIN_LENGTH = 12 class ToolManagementService: @@ -356,6 +357,63 @@ class ToolManagementService: "supported_statuses": list(_REVIEW_QUEUE_STATUSES), } + def build_review_detail_payload(self, version_id: str) -> dict: + 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.") + + 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.") + + validation_payload = {} + if self.artifact_repository is not None: + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if validation_artifact is not None: + validation_payload = dict(validation_artifact.payload_json or {}) + + automated_validation = self._extract_latest_automated_validation(version.id) + generated_source_code = str(validation_payload.get("generated_source_code") or "").strip() + + return { + "version_id": version.version_id, + "tool_name": version.tool_name, + "display_name": metadata.display_name, + "domain": metadata.domain, + "version_number": version.version_number, + "status": version.status, + "summary": version.summary, + "description": metadata.description, + "business_goal": version.business_goal, + "owner_name": version.owner_display_name, + "parameters": self._serialize_parameters_for_response(metadata.parameters_json), + "queue_entry": self._serialize_review_queue_entry(version), + "automated_validations": list(validation_payload.get("automated_checks") or []), + "automated_validation_summary": automated_validation.get("summary"), + "generated_module": build_generated_tool_module_name(version.tool_name), + "generated_callable": GENERATED_TOOL_ENTRYPOINT, + "generated_source_code": generated_source_code, + "human_gate": self._build_human_review_gate(version), + "decision_history": self._list_governance_history_entries(version.id), + "next_steps": self._build_review_detail_next_steps(version, bool(generated_source_code)), + } + def build_publications_payload(self) -> dict: publications_by_tool_name = { publication["tool_name"]: publication @@ -481,7 +539,7 @@ class ToolManagementService: pipeline_snapshot = self._build_pipeline_snapshot(version.status) if automated_validation_result and automated_validation_result["passed"]: message = ( - "Pipeline de geracao executado com sucesso e as validacoes automaticas de contrato, assinatura e schema passaram. " + "Pipeline de geracao executado com sucesso e as validacoes automaticas de contrato, assinatura, importacao e smoke tests passaram. " "A versao agora segue para a proxima etapa de validacao governada." ) next_steps = [ @@ -490,11 +548,11 @@ class ToolManagementService: ] else: message = ( - "Pipeline de geracao executado, mas alguma validacao automatica de contrato, assinatura ou schema falhou. " + "Pipeline de geracao executado, mas alguma validacao automatica de contrato, assinatura, importacao ou smoke test falhou. " "A versao foi marcada como failed para ajuste e nova tentativa." ) next_steps = [ - "Ajustar metadados, assinatura esperada e schema dos parametros antes de rodar o pipeline novamente.", + "Ajustar metadados, assinatura esperada, importacao do modulo e smoke tests antes de rodar o pipeline novamente.", "Enquanto alguma validacao automatica falhar, a versao nao pode seguir para aprovacao e ativacao.", ] return { @@ -517,9 +575,27 @@ class ToolManagementService: reviewer_staff_account_id: int, reviewer_name: str, reviewer_role: StaffRole | str, + decision_notes: str, + reviewed_generated_code: bool, ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) + normalized_version_id = str(version_id or "").strip().lower() + version = ( + self.version_repository.get_by_version_id(normalized_version_id) + if self.version_repository is not None + else None + ) + if version is not None and version.status == ToolLifecycleStatus.GENERATED: + if not reviewed_generated_code: + raise ValueError( + "A revisao humana exige confirmar que o codigo gerado foi analisado antes da validacao." + ) + if not self._version_has_generated_source(normalized_version_id): + raise ValueError( + "A revisao humana exige que a pipeline tenha registrado o codigo completo gerado para esta versao." + ) return self._transition_version_status( - version_id, + normalized_version_id, target_status=ToolLifecycleStatus.VALIDATED, allowed_current_statuses=(ToolLifecycleStatus.GENERATED,), actor_staff_account_id=reviewer_staff_account_id, @@ -529,6 +605,8 @@ class ToolManagementService: 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.", + decision_notes=normalized_notes, + reviewed_generated_code=reviewed_generated_code, 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.", @@ -542,7 +620,9 @@ class ToolManagementService: approver_staff_account_id: int, approver_name: str, approver_role: StaffRole | str, + decision_notes: str, ) -> dict: + normalized_notes = self._normalize_human_decision_notes(decision_notes) return self._transition_version_status( version_id, target_status=ToolLifecycleStatus.APPROVED, @@ -554,6 +634,7 @@ class ToolManagementService: 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.", + decision_notes=normalized_notes, 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.", @@ -585,6 +666,176 @@ class ToolManagementService: ], ) + def deactivate_version( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + payload = self._transition_version_status( + version_id, + target_status=ToolLifecycleStatus.ARCHIVED, + allowed_current_statuses=(ToolLifecycleStatus.ACTIVE,), + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=actor_role, + required_permission=AdminPermission.PUBLISH_TOOLS, + artifact_kind=ToolArtifactKind.PUBLICATION_DEACTIVATION, + artifact_summary="Publicacao ativa desativada pela diretoria.", + success_message="Versao ativa desativada com sucesso e retirada do catalogo governado.", + decision_notes=self._normalize_human_decision_notes(decision_notes), + next_steps=[ + "A versao saiu do catalogo ativo e agora permanece apenas para historico e auditoria.", + "Se houver uma versao arquivada anterior da mesma tool, a diretoria pode executar rollback controlado quando necessario.", + ], + ) + payload["queue_entry"] = None + return payload + + def rollback_version( + self, + version_id: str, + *, + actor_staff_account_id: int, + actor_name: str, + actor_role: StaffRole | str, + decision_notes: str, + ) -> dict: + normalized_role = normalize_staff_role(actor_role) + if not role_has_permission(normalized_role, AdminPermission.PUBLISH_TOOLS): + raise PermissionError( + f"Papel '{normalized_role.value}' sem permissao administrativa '{AdminPermission.PUBLISH_TOOLS.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() + current_version = self.version_repository.get_by_version_id(normalized_version_id) + if current_version is None: + raise LookupError("Versao administrativa nao encontrada.") + if current_version.status != ToolLifecycleStatus.ACTIVE: + raise ValueError( + f"O rollback exige uma versao atualmente active, mas a versao esta em '{current_version.status.value}'." + ) + + rollback_version = self._find_latest_archived_version( + tool_name=current_version.tool_name, + excluding_version_id=current_version.id, + ) + if rollback_version is None: + raise ValueError("Nenhuma versao arquivada disponivel para rollback controlado desta tool.") + + draft = self.draft_repository.get_by_tool_name(current_version.tool_name) + if draft is None: + raise RuntimeError("Draft raiz da tool nao encontrado para o rollback governado.") + current_metadata = self.metadata_repository.get_by_tool_version_id(current_version.id) + rollback_metadata = self.metadata_repository.get_by_tool_version_id(rollback_version.id) + if current_metadata is None or rollback_metadata is None: + raise RuntimeError("Metadados persistidos nao encontrados para executar o rollback governado.") + + normalized_notes = self._normalize_human_decision_notes(decision_notes) + 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: + self._ensure_human_governance_ready_for_activation(rollback_version.id) + self.version_repository.update_status( + current_version, + status=ToolLifecycleStatus.ARCHIVED, + **atomic_write_options, + ) + self.metadata_repository.update_status( + current_metadata, + status=ToolLifecycleStatus.ARCHIVED, + **atomic_write_options, + ) + self.version_repository.update_status( + rollback_version, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self.metadata_repository.update_status( + rollback_metadata, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self.draft_repository.update_status( + draft, + status=ToolLifecycleStatus.ACTIVE, + **atomic_write_options, + ) + self._persist_governance_artifact( + draft=draft, + version=current_version, + artifact_kind=ToolArtifactKind.PUBLICATION_DEACTIVATION, + summary="Versao ativa desativada para permitir rollback controlado.", + previous_status=ToolLifecycleStatus.ACTIVE, + current_status=ToolLifecycleStatus.ARCHIVED, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + decision_notes=normalized_notes, + extra_payload={ + "deactivated_for_rollback": True, + "rollback_target_version_id": rollback_version.version_id, + "rollback_target_version_number": rollback_version.version_number, + }, + commit=artifact_commit, + ) + self._persist_governance_artifact( + draft=draft, + version=rollback_version, + artifact_kind=ToolArtifactKind.PUBLICATION_ROLLBACK, + summary="Rollback controlado executado para restaurar a versao arquivada no catalogo ativo.", + previous_status=ToolLifecycleStatus.ARCHIVED, + current_status=ToolLifecycleStatus.ACTIVE, + actor_staff_account_id=actor_staff_account_id, + actor_name=actor_name, + actor_role=normalized_role, + decision_notes=normalized_notes, + extra_payload={ + "rollback_from_version_id": current_version.version_id, + "rollback_from_version_number": current_version.version_number, + }, + commit=artifact_commit, + ) + if repository_session is not None: + self._commit_repository_session( + repository_session, + draft=draft, + version=rollback_version, + ) + except Exception: + if repository_session is not None: + repository_session.rollback() + raise + + return { + "message": ( + "Rollback executado com sucesso e a versao arquivada voltou ao catalogo governado como ativa." + ), + "version_id": rollback_version.version_id, + "tool_name": rollback_version.tool_name, + "version_number": rollback_version.version_number, + "status": rollback_version.status, + "queue_entry": None, + "publication": self._serialize_metadata_publication(rollback_metadata), + "next_steps": [ + "A versao restaurada voltou ao catalogo ativo do produto sob governanca da diretoria.", + "A versao que estava ativa foi arquivada para manter trilha auditavel do rollback controlado.", + ], + } + def _transition_version_status( self, version_id: str, @@ -598,6 +849,8 @@ class ToolManagementService: artifact_kind: ToolArtifactKind, artifact_summary: str, success_message: str, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, next_steps: list[str], ) -> dict: normalized_role = normalize_staff_role(actor_role) @@ -644,6 +897,7 @@ class ToolManagementService: try: if target_status == ToolLifecycleStatus.ACTIVE: + self._ensure_human_governance_ready_for_activation(version.id) self._archive_active_publications( tool_name=version.tool_name, excluding_version_id=version.id, @@ -675,6 +929,8 @@ class ToolManagementService: actor_staff_account_id=actor_staff_account_id, actor_name=actor_name, actor_role=normalized_role, + decision_notes=decision_notes, + reviewed_generated_code=reviewed_generated_code, commit=artifact_commit, ) if repository_session is not None: @@ -995,6 +1251,9 @@ class ToolManagementService: actor_staff_account_id: int, actor_name: str, actor_role: StaffRole, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, + extra_payload: dict | None = None, commit: bool | None = None, ) -> None: if self.artifact_repository is None: @@ -1018,6 +1277,9 @@ class ToolManagementService: actor_staff_account_id=actor_staff_account_id, actor_name=actor_name, actor_role=actor_role, + decision_notes=decision_notes, + reviewed_generated_code=reviewed_generated_code, + extra_payload=extra_payload, ), author_staff_account_id=actor_staff_account_id, author_display_name=actor_name, @@ -1034,8 +1296,11 @@ class ToolManagementService: actor_staff_account_id: int, actor_name: str, actor_role: StaffRole, + decision_notes: str | None = None, + reviewed_generated_code: bool | None = None, + extra_payload: dict | None = None, ) -> dict: - return { + payload = { "source": "director_governance", "action": artifact_kind.value, "tool_name": version.tool_name, @@ -1046,7 +1311,12 @@ class ToolManagementService: "actor_staff_account_id": actor_staff_account_id, "actor_display_name": actor_name, "actor_role": actor_role.value, + "decision_notes": str(decision_notes or "").strip() or None, + "reviewed_generated_code": reviewed_generated_code, } + if extra_payload: + payload.update(extra_payload) + return payload def _persist_initial_version_artifacts( self, @@ -1341,6 +1611,14 @@ class ToolManagementService: "signature_schema": dict(signature_schema_blueprint), "import_loading": dict(import_loading_result), "smoke_tests": dict(smoke_test_result), + "generated_source_code": ( + str(import_loading_result.get("rendered_source") or "").strip() + or self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ) + ), "publication_envelope": publication_envelope, } @@ -1499,6 +1777,11 @@ class ToolManagementService: "loaded_callable": GENERATED_TOOL_ENTRYPOINT, "loaded_signature": None, "sandbox_package_root": None, + "rendered_source": self._render_generated_tool_module_source( + version=version, + metadata=metadata, + signature_schema_blueprint=signature_schema_blueprint, + ), "issues": [ "generated import/loading validation skipped because the signature/schema blueprint is invalid." ], @@ -1538,6 +1821,7 @@ class ToolManagementService: "loaded_callable": load_result["loaded_callable"], "loaded_signature": loaded_signature, "sandbox_package_root": load_result["sandbox_package_root"], + "rendered_source": load_result["rendered_source"], "issues": issues, } @@ -1973,6 +2257,189 @@ class ToolManagementService: "queued_at": version.updated_at or version.created_at, } + def _version_has_generated_source(self, version_id: str) -> bool: + if self.version_repository is None or self.artifact_repository is None: + return False + + normalized_version_id = str(version_id or "").strip().lower() + version = self.version_repository.get_by_version_id(normalized_version_id) + if version is None: + return False + + validation_artifact = self.artifact_repository.get_by_tool_version_and_kind( + version.id, + ToolArtifactKind.VALIDATION_REPORT, + ) + if validation_artifact is None: + return False + generated_source_code = str((validation_artifact.payload_json or {}).get("generated_source_code") or "").strip() + return bool(generated_source_code) + + def _find_latest_archived_version( + self, + *, + tool_name: str, + excluding_version_id: int | None = None, + ) -> ToolVersion | None: + if self.version_repository is None: + return None + + for archived_version in self.version_repository.list_versions( + tool_name=tool_name, + statuses=(ToolLifecycleStatus.ARCHIVED,), + ): + if excluding_version_id is not None and archived_version.id == excluding_version_id: + continue + return archived_version + return None + + @staticmethod + def _normalize_human_decision_notes(decision_notes: str) -> str: + normalized_notes = str(decision_notes or "").strip() + if len(normalized_notes) < _HUMAN_DECISION_NOTES_MIN_LENGTH: + raise ValueError( + "A decisao humana precisa registrar um parecer com pelo menos " + f"{_HUMAN_DECISION_NOTES_MIN_LENGTH} caracteres." + ) + return normalized_notes + + def _ensure_human_governance_ready_for_activation(self, tool_version_id: int) -> None: + if self.artifact_repository is None: + raise RuntimeError( + "A ativacao governada exige trilha de auditoria habilitada para validar a aprovacao humana." + ) + + review_artifact = self.artifact_repository.get_by_tool_version_and_kind( + tool_version_id, + ToolArtifactKind.DIRECTOR_REVIEW, + ) + approval_artifact = self.artifact_repository.get_by_tool_version_and_kind( + tool_version_id, + ToolArtifactKind.DIRECTOR_APPROVAL, + ) + + review_payload = dict(review_artifact.payload_json or {}) if review_artifact is not None else {} + approval_payload = dict(approval_artifact.payload_json or {}) if approval_artifact is not None else {} + + if not review_payload.get("decision_notes") or not bool(review_payload.get("reviewed_generated_code")): + raise ValueError( + "A ativacao exige uma revisao humana registrada com parecer e confirmacao de leitura do codigo gerado." + ) + if not approval_payload.get("decision_notes"): + raise ValueError( + "A ativacao exige uma aprovacao humana registrada com parecer explicito da diretoria." + ) + + def _build_human_review_gate(self, version: ToolVersion) -> dict: + rollback_candidate = None + if version.status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=version.tool_name, + excluding_version_id=version.id, + ) + return { + "current_gate": ToolManagementService._build_review_gate(version.status), + "review_action_available": version.status == ToolLifecycleStatus.GENERATED, + "approval_action_available": version.status == ToolLifecycleStatus.VALIDATED, + "publication_action_available": version.status == ToolLifecycleStatus.APPROVED, + "deactivation_action_available": version.status == ToolLifecycleStatus.ACTIVE, + "rollback_action_available": version.status == ToolLifecycleStatus.ACTIVE and rollback_candidate is not None, + "rollback_target_version_id": rollback_candidate.version_id if rollback_candidate is not None else None, + "rollback_target_version_number": rollback_candidate.version_number if rollback_candidate is not None else None, + "requires_decision_notes": version.status in { + ToolLifecycleStatus.GENERATED, + ToolLifecycleStatus.VALIDATED, + ToolLifecycleStatus.ACTIVE, + }, + "requires_code_review_confirmation": version.status == ToolLifecycleStatus.GENERATED, + } + + def _build_review_detail_next_steps( + self, + version: ToolVersion, + generated_source_available: bool, + ) -> list[str]: + status = version.status + next_steps_by_status = { + ToolLifecycleStatus.DRAFT: [ + "Execute a pipeline de geracao para produzir o modulo governado antes da revisao humana.", + "Enquanto a versao estiver em draft, ela permanece fora da aprovacao e da ativacao.", + ], + ToolLifecycleStatus.GENERATED: [ + "Analise o codigo completo gerado, confirme a leitura manual e registre a revisao da diretoria.", + "Somente depois da revisao humana a versao pode seguir para aprovacao formal.", + ], + ToolLifecycleStatus.VALIDATED: [ + "Registre o parecer final de aprovacao da diretoria antes da publicacao.", + "A ativacao continua bloqueada ate existir aprovacao humana explicita.", + ], + ToolLifecycleStatus.APPROVED: [ + "A versao ja foi aprovada pela diretoria e agora pode seguir para publicacao controlada.", + "A ativacao vai validar novamente a trilha de revisao e aprovacao humana antes de entrar no catalogo.", + ], + ToolLifecycleStatus.ACTIVE: [ + "A versao esta ativa no catalogo governado e pode ser desativada com parecer explicito da diretoria.", + "Quando houver uma versao arquivada anterior, o rollback controlado pode restaurar rapidamente a publicacao anterior.", + ], + ToolLifecycleStatus.ARCHIVED: [ + "Esta versao foi retirada do catalogo ativo e permanece arquivada para historico e auditoria.", + "A diretoria pode restaurar uma versao arquivada por rollback controlado a partir da publicacao ativa correspondente.", + ], + ToolLifecycleStatus.FAILED: [ + "Corrija os bloqueios da pipeline e execute uma nova geracao antes de voltar para a revisao humana.", + "Enquanto a versao estiver em failed, a aprovacao e a ativacao permanecem indisponiveis.", + ], + } + next_steps = list(next_steps_by_status.get(status, ["Acompanhe a governanca da versao pela trilha administrativa."])) + if status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=version.tool_name, + excluding_version_id=version.id, + ) + if rollback_candidate is not None: + next_steps.append( + f"Ha uma versao arquivada disponivel para rollback: v{rollback_candidate.version_number}." + ) + if not generated_source_available: + next_steps.append("O codigo completo aparece aqui assim que a pipeline gerar e registrar a funcao governada.") + return next_steps + + def _list_governance_history_entries(self, tool_version_id: int) -> list[dict]: + if self.artifact_repository is None: + return [] + + history_entries = self.artifact_repository.list_artifacts( + tool_version_id=tool_version_id, + artifact_stage=ToolArtifactStage.GOVERNANCE, + ) + return [ + self._serialize_governance_history_entry(artifact) + for artifact in reversed(history_entries) + ] + + @staticmethod + def _serialize_governance_history_entry(artifact) -> dict: + payload = dict(artifact.payload_json or {}) + label_by_kind = { + ToolArtifactKind.DIRECTOR_REVIEW: "Revisao humana registrada", + ToolArtifactKind.DIRECTOR_APPROVAL: "Aprovacao humana registrada", + ToolArtifactKind.PUBLICATION_RELEASE: "Publicacao administrativa registrada", + ToolArtifactKind.PUBLICATION_DEACTIVATION: "Desativacao registrada", + ToolArtifactKind.PUBLICATION_ROLLBACK: "Rollback registrado", + } + return { + "action_key": artifact.artifact_kind.value, + "label": label_by_kind.get(artifact.artifact_kind, "Governanca registrada"), + "summary": artifact.summary, + "previous_status": payload.get("previous_status"), + "current_status": payload.get("current_status"), + "actor_name": payload.get("actor_display_name"), + "actor_role": payload.get("actor_role"), + "decision_notes": payload.get("decision_notes"), + "reviewed_generated_code": payload.get("reviewed_generated_code"), + "recorded_at": artifact.updated_at or artifact.created_at, + } + @staticmethod def _build_review_gate(status: ToolLifecycleStatus) -> str: gate_by_status = { @@ -1980,6 +2447,8 @@ class ToolManagementService: ToolLifecycleStatus.GENERATED: "validation_required", ToolLifecycleStatus.VALIDATED: "director_approval_required", ToolLifecycleStatus.APPROVED: "director_publication_required", + ToolLifecycleStatus.ACTIVE: "publication_active", + ToolLifecycleStatus.ARCHIVED: "archived_history", ToolLifecycleStatus.FAILED: "pipeline_retry_required", } return gate_by_status.get(status, "governance_required") @@ -2035,6 +2504,18 @@ class ToolManagementService: def _serialize_metadata_publication(self, metadata: ToolMetadata) -> dict: parameters = self._serialize_parameters_for_response(metadata.parameters_json) + version_record = None + if self.version_repository is not None: + for candidate in self.version_repository.list_versions(tool_name=metadata.tool_name): + if candidate.id == metadata.tool_version_id: + version_record = candidate + break + rollback_candidate = None + if metadata.status == ToolLifecycleStatus.ACTIVE: + rollback_candidate = self._find_latest_archived_version( + tool_name=metadata.tool_name, + excluding_version_id=metadata.tool_version_id, + ) return { "publication_id": metadata.metadata_id, "tool_name": metadata.tool_name, @@ -2043,6 +2524,7 @@ class ToolManagementService: "domain": metadata.domain, "version": metadata.version_number, "status": metadata.status, + "version_id": version_record.version_id if version_record is not None else None, "parameter_count": len(parameters), "parameters": parameters, "author_name": metadata.author_display_name, @@ -2050,6 +2532,10 @@ class ToolManagementService: "implementation_callable": GENERATED_TOOL_ENTRYPOINT, "published_by": metadata.author_display_name, "published_at": metadata.updated_at or metadata.created_at, + "deactivation_action_available": metadata.status == ToolLifecycleStatus.ACTIVE and version_record is not None, + "rollback_action_available": metadata.status == ToolLifecycleStatus.ACTIVE and rollback_candidate is not None, + "rollback_target_version_id": rollback_candidate.version_id if rollback_candidate is not None else None, + "rollback_target_version_number": rollback_candidate.version_number if rollback_candidate is not None else None, } def _serialize_draft_summary(self, draft: ToolDraft) -> dict: diff --git a/admin_app/view/rendering.py b/admin_app/view/rendering.py index be73971..ebe2564 100644 --- a/admin_app/view/rendering.py +++ b/admin_app/view/rendering.py @@ -672,6 +672,88 @@ def render_tool_review_page( +
+
+
+
+

Detalhe da versao

+

Revisao humana antes da ativacao

+

Selecione uma versao da fila para validar o contrato, inspecionar o codigo completo gerado e registrar a decisao da diretoria.

+
+ Nenhum item +
+
+
+
+
+
Selecione um item da fila
+

O detalhe da versao aparece aqui junto com o resumo funcional e o gate humano atual.

+
+ +
+

Contexto e parametros

+
+
Nenhuma versao selecionada.
+
+
+ +
+

Validacoes automaticas

+
+
As validacoes da pipeline aparecem aqui.
+
+
+ +
+

Historico da diretoria

+
+
Nenhuma decisao humana registrada ainda.
+
+
+ +
+

Proximos passos

+
+
Os proximos passos da versao aparecem aqui.
+
+
+
+
+ +
+
+
+ + +
Use este campo para revisar a implementacao completa antes de validar, aprovar e ativar a nova tool.
+
+ +
+ + +
As notas da decisao ficam persistidas na trilha administrativa da versao.
+
+ +
+ + +
+ +
+ + + + + +
+
+
+
+
+
+
diff --git a/admin_app/view/router.py b/admin_app/view/router.py index 4d7282f..8c26d97 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -762,7 +762,7 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT review_notes=( "Conferir se o gate do item combina com o estado esperado do lifecycle.", "Observar se a descricao e o objetivo operacional da tool estao claros para o time.", - "Usar o catalogo ativo como comparativo antes de promover uma nova versao.", + "Ler o codigo completo gerado antes de validar manualmente a versao.", ), approval_notes=( "Verificar nome, descricao e semantica dos parametros antes da aprovacao.", diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index 2484102..a354cc9 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -115,23 +115,96 @@ function mountToolReviewBoard(board) { const publicationList = board.querySelector("[data-tool-publication-list]"); const lifecycleList = board.querySelector("[data-tool-contract-lifecycle]"); const parameterTypes = board.querySelector("[data-tool-parameter-types]"); + const detailStatus = board.querySelector("[data-tool-review-detail-status]"); + const detailSummary = board.querySelector("[data-tool-review-detail-summary]"); + const detailTitle = board.querySelector("[data-tool-review-detail-title]"); + const detailMeta = board.querySelector("[data-tool-review-detail-meta]"); + const validationList = board.querySelector("[data-tool-review-validation-list]"); + const historyList = board.querySelector("[data-tool-review-history-list]"); + const nextStepsList = board.querySelector("[data-tool-review-next-steps]"); + const codeField = board.querySelector("[data-tool-review-code]"); + const decisionNotes = board.querySelector("[data-tool-review-decision-notes]"); + const decisionHint = board.querySelector("[data-tool-review-decision-hint]"); + const reviewedGeneratedCode = board.querySelector("[data-tool-review-reviewed-code]"); + const reviewButton = board.querySelector('[data-tool-review-action="review"]'); + const approveButton = board.querySelector('[data-tool-review-action="approve"]'); + const publishButton = board.querySelector('[data-tool-review-action="publish"]'); + const deactivateButton = board.querySelector('[data-tool-review-action="deactivate"]'); + const rollbackButton = board.querySelector('[data-tool-review-action="rollback"]'); + + let selectedVersionId = ""; + let lastRenderedHumanGate = null; + let lastRenderedHasSourceCode = false; if (refreshButton) { refreshButton.addEventListener("click", () => { - void loadBoard(); + void loadBoard(selectedVersionId); }); } + if (queueList) { + queueList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const trigger = target.closest("[data-tool-review-select]"); + if (!(trigger instanceof HTMLElement)) { + return; + } + const nextVersionId = String(trigger.dataset.versionId || "").trim(); + if (!nextVersionId) { + return; + } + void loadReviewDetail(nextVersionId); + }); + } + + if (publicationList) { + publicationList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + const trigger = target.closest("[data-tool-publication-select]"); + if (!(trigger instanceof HTMLElement)) { + return; + } + const nextVersionId = String(trigger.dataset.versionId || "").trim(); + if (!nextVersionId) { + return; + } + void loadReviewDetail(nextVersionId); + }); + } + + [reviewButton, approveButton, publishButton, deactivateButton, rollbackButton].forEach((button) => { + if (!(button instanceof HTMLButtonElement)) { + return; + } + button.dataset.defaultLabel = button.textContent || ""; + button.addEventListener("click", () => { + const actionKey = String(button.dataset.toolReviewAction || "").trim(); + if (!actionKey) { + return; + } + void submitGovernanceAction(actionKey); + }); + }); + + renderEmptyDetail("Selecione um item da fila para carregar o contexto completo da revisao humana."); void loadBoard(); - async function loadBoard() { + async function loadBoard(preferredVersionId = "") { toggleRefreshing(true); clearFeedback(); - const overviewResult = await fetchPanelJson(board.dataset.overviewEndpoint); - const contractsResult = await fetchPanelJson(board.dataset.contractsEndpoint); - const reviewQueueResult = await fetchPanelJson(board.dataset.reviewQueueEndpoint); - const publicationsResult = await fetchPanelJson(board.dataset.publicationsEndpoint); + const [overviewResult, contractsResult, reviewQueueResult, publicationsResult] = await Promise.all([ + fetchPanelJson(board.dataset.overviewEndpoint), + fetchPanelJson(board.dataset.contractsEndpoint), + fetchPanelJson(board.dataset.reviewQueueEndpoint), + fetchPanelJson(board.dataset.publicationsEndpoint), + ]); if (!overviewResult.ok && !contractsResult.ok && !reviewQueueResult.ok && !publicationsResult.ok) { showFeedback("warning", overviewResult.message || "Entre com uma sessao administrativa web para carregar esta tela."); @@ -146,9 +219,29 @@ function mountToolReviewBoard(board) { renderLockedLifecycle(contractsResult.message); } if (reviewQueueResult.ok) { - renderReviewQueue(reviewQueueResult.body); + renderReviewQueue(reviewQueueResult.body, preferredVersionId || selectedVersionId); + const items = Array.isArray(reviewQueueResult.body?.items) ? reviewQueueResult.body.items : []; + if (items.length > 0) { + const nextVersionId = items.some((item) => item?.version_id === (preferredVersionId || selectedVersionId)) + ? (preferredVersionId || selectedVersionId) + : String(items[0]?.version_id || "").trim(); + if (nextVersionId) { + await loadReviewDetail(nextVersionId); + } else { + renderEmptyDetail(reviewQueueResult.body?.message || "Nenhuma versao pronta para detalhe."); + } + } else { + const fallbackVersionId = String(preferredVersionId || selectedVersionId || "").trim(); + if (fallbackVersionId) { + await loadReviewDetail(fallbackVersionId); + } else { + selectedVersionId = ""; + renderEmptyDetail(reviewQueueResult.body?.message || "Nenhuma versao aguardando revisao neste momento."); + } + } } else { renderLockedQueue(reviewQueueResult.message); + renderLockedDetail(reviewQueueResult.message || "A sessao atual nao pode acessar o detalhe de revisao."); } if (publicationsResult.ok) { renderPublications(publicationsResult.body); @@ -160,6 +253,99 @@ function mountToolReviewBoard(board) { toggleRefreshing(false); } + async function loadReviewDetail(versionId) { + const normalizedVersionId = String(versionId || "").trim(); + if (!normalizedVersionId) { + renderEmptyDetail("Selecione uma versao valida para abrir o detalhe da revisao."); + return; + } + + selectedVersionId = normalizedVersionId; + renderDetailLoading(); + + const detailUrl = `${board.dataset.reviewQueueEndpoint}/${encodeURIComponent(normalizedVersionId)}`; + const detailResult = await fetchPanelJson(detailUrl); + if (!detailResult.ok) { + renderLockedDetail(detailResult.message || "Nao foi possivel carregar o detalhe da versao selecionada."); + showFeedback("warning", detailResult.message || "Nao foi possivel carregar o detalhe da revisao humana."); + return; + } + + renderReviewDetail(detailResult.body); + renderReviewQueueSelection(normalizedVersionId); + } + + async function submitGovernanceAction(actionKey) { + if (!selectedVersionId) { + showFeedback("warning", "Selecione uma versao da fila antes de registrar uma decisao humana."); + return; + } + + const actionUrl = resolveGovernanceActionUrl(actionKey, selectedVersionId); + if (!actionUrl) { + showFeedback("warning", "A acao solicitada nao esta disponivel para esta versao."); + return; + } + + let payload; + if (actionKey === "review") { + payload = { + decision_notes: String(decisionNotes?.value || "").trim(), + reviewed_generated_code: Boolean(reviewedGeneratedCode?.checked), + }; + } else if (actionKey === "approve" || actionKey === "deactivate" || actionKey === "rollback") { + payload = { + decision_notes: String(decisionNotes?.value || "").trim(), + }; + } + + toggleActionLoading(actionKey, true); + clearFeedback(); + + try { + const response = await fetch(actionUrl, { + method: "POST", + credentials: "same-origin", + headers: { + Accept: "application/json", + ...(payload ? { "Content-Type": "application/json" } : {}), + }, + ...(payload ? { body: JSON.stringify(payload) } : {}), + }); + const body = await readJson(response); + if (!response.ok) { + throw new Error(body?.detail || "Nao foi possivel registrar a decisao humana desta versao."); + } + + if (decisionNotes instanceof HTMLTextAreaElement) { + decisionNotes.value = ""; + } + if (reviewedGeneratedCode instanceof HTMLInputElement) { + reviewedGeneratedCode.checked = false; + } + showFeedback("success", body?.message || "Decisao humana registrada com sucesso."); + await loadBoard(body?.version_id || selectedVersionId); + } catch (error) { + showFeedback("danger", error instanceof Error ? error.message : "Erro inesperado ao registrar a decisao humana."); + } finally { + toggleActionLoading(actionKey, false); + } + } + + function resolveGovernanceActionUrl(actionKey, versionId) { + const encodedVersionId = encodeURIComponent(String(versionId || "").trim()); + if (!encodedVersionId) { + return ""; + } + if (actionKey === "publish" || actionKey === "deactivate" || actionKey === "rollback") { + return `${board.dataset.publicationsEndpoint}/${encodedVersionId}/${actionKey}`; + } + if (actionKey === "review" || actionKey === "approve") { + return `${board.dataset.reviewQueueEndpoint}/${encodedVersionId}/${actionKey}`; + } + return ""; + } + function toggleRefreshing(isLoading) { if (!refreshButton || !refreshLabel || !refreshSpinner) { return; @@ -169,6 +355,26 @@ function mountToolReviewBoard(board) { refreshLabel.textContent = isLoading ? "Atualizando..." : "Atualizar leitura"; } + function toggleActionLoading(actionKey, isLoading) { + const buttonsByAction = { + review: reviewButton, + approve: approveButton, + publish: publishButton, + deactivate: deactivateButton, + rollback: rollbackButton, + }; + const button = buttonsByAction[actionKey]; + if (!(button instanceof HTMLButtonElement)) { + return; + } + const defaultLabel = button.dataset.defaultLabel || button.textContent || ""; + button.disabled = isLoading || button.disabled; + button.textContent = isLoading ? "Processando..." : defaultLabel; + if (!isLoading) { + configureActionPanel(lastRenderedHumanGate, lastRenderedHasSourceCode); + } + } + function clearFeedback() { feedback.className = "alert d-none rounded-4 mb-4"; feedback.textContent = ""; @@ -204,15 +410,46 @@ function mountToolReviewBoard(board) { parameterTypes.innerHTML = `Bloqueado`; } - function renderReviewQueue(payload) { + function renderReviewQueue(payload, preferredVersionId = "") { const items = Array.isArray(payload?.items) ? payload.items : []; setText("[data-tool-review-queue-count]", String(items.length)); setText("[data-tool-review-queue-mode]", payload?.queue_mode || "Fila web"); queueList.innerHTML = items.length > 0 - ? items.map((item) => `
${escapeHtml(item.gate || "revisao")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
${escapeHtml(item.status || "pendente")}

${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}

`).join("") + ? items.map((item) => { + const isSelected = String(item?.version_id || "") === String(preferredVersionId || selectedVersionId || ""); + const validationSummary = item?.automated_validation_summary + ? `
Pipeline: ${escapeHtml(item.automated_validation_summary)}
` + : ""; + const queuedAt = item?.queued_at + ? `
Atualizado: ${escapeHtml(formatDateTime(item.queued_at))}
` + : ""; + return `
${escapeHtml(item.gate || "revisao")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
${escapeHtml(item.status || "pendente")}

${escapeHtml(item.summary || payload?.message || "Item aguardando analise do time.")}

${validationSummary}${queuedAt}
`; + }).join("") : `

Fila sem itens no momento

${escapeHtml(payload?.message || "Nenhuma tool aguardando revisao agora.")}

`; } + function renderReviewQueueSelection(versionId) { + const normalizedVersionId = String(versionId || "").trim(); + queueList.querySelectorAll("[data-tool-review-select]").forEach((button) => { + if (!(button instanceof HTMLButtonElement)) { + return; + } + const isSelected = String(button.dataset.versionId || "") === normalizedVersionId; + button.classList.toggle("btn-dark", isSelected); + button.classList.toggle("btn-outline-dark", !isSelected); + button.textContent = isSelected ? "Versao selecionada" : "Abrir detalhe"; + }); + queueList.querySelectorAll(".admin-tool-review-card").forEach((card) => { + if (!(card instanceof HTMLElement)) { + return; + } + const cardButton = card.querySelector("[data-tool-review-select]"); + const isSelected = cardButton instanceof HTMLElement && String(cardButton.dataset.versionId || "") === normalizedVersionId; + card.classList.toggle("border", isSelected); + card.classList.toggle("border-dark", isSelected); + }); + } + function renderLockedQueue(message) { setText("[data-tool-review-queue-count]", "0"); setText("[data-tool-review-queue-mode]", "Bloqueado"); @@ -224,7 +461,18 @@ function mountToolReviewBoard(board) { setText("[data-tool-review-publication-count]", String(items.length)); setText("[data-tool-publication-source]", payload?.source || "Catalogo web"); publicationList.innerHTML = items.length > 0 - ? items.slice(0, 9).map((item) => `
${escapeHtml(item.domain || "tool")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}

${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}

Status: ${escapeHtml(item.status || "draft")}
Parametros: ${escapeHtml(String(item.parameter_count || 0))}
Autor: ${escapeHtml(item.author_name || item.published_by || "Nao informado")}
${escapeHtml(item.implementation_module || "")}
`).join("") + ? items.slice(0, 9).map((item) => { + const manageButton = item?.version_id + ? `
` + : ""; + const rollbackBadge = item?.rollback_action_available + ? `Rollback disponivel` + : ""; + const deactivateBadge = item?.deactivation_action_available + ? `Desativacao disponivel` + : ""; + return `
${escapeHtml(item.domain || "tool")}

${escapeHtml(item.display_name || item.tool_name || "Tool")}

${escapeHtml(item.tool_name || "")}
v${escapeHtml(String(item.version || 1))}

${escapeHtml(item.description || "Publicacao ativa no catalogo do produto.")}

${deactivateBadge}${rollbackBadge}
Status: ${escapeHtml(item.status || "draft")}
Parametros: ${escapeHtml(String(item.parameter_count || 0))}
Autor: ${escapeHtml(item.author_name || item.published_by || "Nao informado")}
${escapeHtml(item.implementation_module || "")}
${manageButton}
`; + }).join("") : `

Catalogo ativo vazio

Nenhuma publicacao ativa retornada pela sessao web.

`; } @@ -233,6 +481,195 @@ function mountToolReviewBoard(board) { setText("[data-tool-publication-source]", "Bloqueado"); publicationList.innerHTML = `

Catalogo protegido

${escapeHtml(message || "A sessao atual nao possui permissao para ler as publicacoes ativas.")}

`; } + + function renderDetailLoading() { + detailStatus.textContent = "Carregando"; + detailTitle.textContent = "Sincronizando detalhe da versao"; + detailSummary.innerHTML = `
Carregando contexto governado

A leitura do detalhe da versao esta em andamento.

`; + detailMeta.innerHTML = `
Carregando metadados persistidos...
`; + validationList.innerHTML = `
Carregando validacoes automaticas...
`; + historyList.innerHTML = `
Carregando historico humano...
`; + nextStepsList.innerHTML = `
Carregando proximos passos...
`; + if (codeField instanceof HTMLTextAreaElement) { + codeField.value = "Carregando codigo gerado..."; + } + configureActionPanel(null, false); + } + + function renderEmptyDetail(message) { + detailStatus.textContent = "Nenhum item"; + detailTitle.textContent = "Selecione um item da fila"; + detailSummary.innerHTML = `
Revisao humana aguardando selecao

${escapeHtml(message || "Escolha uma versao da fila para abrir o detalhe governado.")}

`; + detailMeta.innerHTML = `
Os metadados persistidos e os parametros da versao aparecem aqui.
`; + validationList.innerHTML = `
As validacoes automaticas da pipeline aparecem aqui.
`; + historyList.innerHTML = `
As decisoes humanas de revisao, aprovacao e publicacao aparecem aqui.
`; + nextStepsList.innerHTML = `
Selecione uma versao para visualizar os proximos passos recomendados.
`; + if (codeField instanceof HTMLTextAreaElement) { + codeField.value = "O codigo completo da funcao gerada aparecera aqui assim que uma versao for selecionada."; + } + if (decisionNotes instanceof HTMLTextAreaElement) { + decisionNotes.value = ""; + } + if (reviewedGeneratedCode instanceof HTMLInputElement) { + reviewedGeneratedCode.checked = false; + } + configureActionPanel(null, false); + } + + function renderLockedDetail(message) { + detailStatus.textContent = "Bloqueado"; + detailTitle.textContent = "Detalhe indisponivel"; + detailSummary.innerHTML = `
Leitura protegida

${escapeHtml(message || "A sessao atual nao pode visualizar o detalhe de revisao desta versao.")}

`; + detailMeta.innerHTML = `
Sem acesso aos metadados desta versao.
`; + validationList.innerHTML = `
Sem acesso ao relatorio de validacao automatica.
`; + historyList.innerHTML = `
Sem acesso ao historico de governanca.
`; + nextStepsList.innerHTML = `
Entre com uma sessao com permissao de revisao para continuar.
`; + if (codeField instanceof HTMLTextAreaElement) { + codeField.value = message || "A leitura do codigo gerado esta protegida pela permissao de revisao."; + } + if (decisionNotes instanceof HTMLTextAreaElement) { + decisionNotes.value = ""; + } + if (reviewedGeneratedCode instanceof HTMLInputElement) { + reviewedGeneratedCode.checked = false; + } + configureActionPanel(null, false); + } + + function renderReviewDetail(payload) { + const parameters = Array.isArray(payload?.parameters) ? payload.parameters : []; + const validations = Array.isArray(payload?.automated_validations) ? payload.automated_validations : []; + const history = Array.isArray(payload?.decision_history) ? payload.decision_history : []; + const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; + const humanGate = payload?.human_gate || null; + const hasSourceCode = Boolean(String(payload?.generated_source_code || "").trim()); + + detailStatus.textContent = payload?.status || "versao"; + detailTitle.innerHTML = `${escapeHtml(payload?.display_name || payload?.tool_name || "Tool")} v${escapeHtml(String(payload?.version_number || 1))}`; + detailSummary.innerHTML = `
${escapeHtml(payload?.summary || "Resumo indisponivel")}

${escapeHtml(payload?.description || "Sem descricao detalhada para esta versao.")}

`; + + const parameterMarkup = parameters.length > 0 + ? parameters.map((item) => `
${escapeHtml(item.name || "parametro")}
${escapeHtml(item.description || "")}
${escapeHtml(item.parameter_type || "string")}${item.required ? " *" : ""}
`).join("") + : `
Esta versao nao declarou parametros de entrada.
`; + detailMeta.innerHTML = ` +
+
Tool: ${escapeHtml(payload?.tool_name || "-")}
+
Dominio: ${escapeHtml(payload?.domain || "-")}
+
Owner: ${escapeHtml(payload?.owner_name || "Nao informado")}
+
Gate atual: ${escapeHtml(humanGate?.current_gate || payload?.queue_entry?.gate || "governance_required")}
+
Modulo: ${escapeHtml(payload?.generated_module || "-")}
+
Entrypoint: ${escapeHtml(payload?.generated_callable || "run")}
+
Resumo da pipeline: ${escapeHtml(payload?.automated_validation_summary || "Sem resumo de validacao automatica.")}
+
+ ${parameterMarkup} +
Objetivo de negocio: ${escapeHtml(payload?.business_goal || "Nao informado")}
+ `; + + validationList.innerHTML = validations.length > 0 + ? validations.map((item) => { + const issues = Array.isArray(item?.blocking_issues) && item.blocking_issues.length > 0 + ? `
Bloqueios: ${escapeHtml(item.blocking_issues.join("; "))}
` + : `
Sem bloqueios nesta checagem.
`; + return `
${escapeHtml(item.label || item.key || "Validacao")}
${escapeHtml(item.summary || "")}
${issues}
${escapeHtml(item.status || "pendente")}
`; + }).join("") + : `
Nenhuma validacao automatica registrada para esta versao.
`; + + historyList.innerHTML = history.length > 0 + ? history.map((item) => { + const statusTransition = item?.previous_status || item?.current_status + ? `
Status: ${escapeHtml(item.previous_status || "-")} -> ${escapeHtml(item.current_status || "-")}
` + : ""; + const decisionNotesMarkup = item?.decision_notes + ? `
Parecer: ${escapeHtml(item.decision_notes)}
` + : ""; + const reviewedMarkup = item?.reviewed_generated_code === true + ? `
Codigo revisado: confirmado
` + : ""; + const actorMarkup = item?.actor_name + ? `
${escapeHtml(item.actor_name)}${item?.actor_role ? ` ? ${escapeHtml(item.actor_role)}` : ""}${item?.recorded_at ? ` ? ${escapeHtml(formatDateTime(item.recorded_at))}` : ""}
` + : ""; + return `
${escapeHtml(item.label || "Governanca registrada")}
${escapeHtml(item.summary || "")}
${actorMarkup}${statusTransition}${decisionNotesMarkup}${reviewedMarkup}
`; + }).join("") + : `
Nenhuma decisao humana registrada ainda para esta versao.
`; + + nextStepsList.innerHTML = nextSteps.length > 0 + ? nextSteps.map((item) => `
${escapeHtml(item)}
`).join("") + : `
Nenhum proximo passo retornado para esta versao.
`; + + if (codeField instanceof HTMLTextAreaElement) { + codeField.value = hasSourceCode + ? String(payload.generated_source_code) + : "A pipeline ainda nao registrou o codigo completo gerado para esta versao."; + } + if (decisionNotes instanceof HTMLTextAreaElement) { + decisionNotes.value = ""; + } + if (reviewedGeneratedCode instanceof HTMLInputElement) { + reviewedGeneratedCode.checked = false; + } + if (decisionHint instanceof HTMLElement) { + decisionHint.textContent = buildDecisionHint(humanGate, hasSourceCode); + } + configureActionPanel(humanGate, hasSourceCode); + } + + function configureActionPanel(humanGate, hasSourceCode) { + lastRenderedHumanGate = humanGate; + lastRenderedHasSourceCode = hasSourceCode; + configureActionButton(reviewButton, Boolean(humanGate?.review_action_available) && hasSourceCode); + configureActionButton(approveButton, Boolean(humanGate?.approval_action_available)); + configureActionButton(publishButton, Boolean(humanGate?.publication_action_available)); + configureActionButton(deactivateButton, Boolean(humanGate?.deactivation_action_available)); + configureActionButton(rollbackButton, Boolean(humanGate?.rollback_action_available)); + + const notesEnabled = Boolean(humanGate?.requires_decision_notes); + if (decisionNotes instanceof HTMLTextAreaElement) { + decisionNotes.disabled = !notesEnabled; + if (!notesEnabled) { + decisionNotes.value = ""; + } + } + if (reviewedGeneratedCode instanceof HTMLInputElement) { + reviewedGeneratedCode.disabled = !Boolean(humanGate?.requires_code_review_confirmation); + if (reviewedGeneratedCode.disabled) { + reviewedGeneratedCode.checked = false; + } + } + } + + function configureActionButton(button, isEnabled) { + if (!(button instanceof HTMLButtonElement)) { + return; + } + const defaultLabel = button.dataset.defaultLabel || button.textContent || ""; + button.textContent = defaultLabel; + button.disabled = !isEnabled; + } + + function buildDecisionHint(humanGate, hasSourceCode) { + if (!humanGate) { + return "As notas da decisao ficam persistidas na trilha administrativa da versao."; + } + if (humanGate.review_action_available && !hasSourceCode) { + return "A revisao humana fica habilitada assim que o codigo completo gerado estiver disponivel para leitura."; + } + if (humanGate.review_action_available) { + return "Para validar a versao, registre o parecer e confirme explicitamente que o codigo completo foi revisado."; + } + if (humanGate.approval_action_available) { + return "A aprovacao formal ainda exige um parecer explicito da diretoria antes da publicacao."; + } + if (humanGate.publication_action_available) { + return "A revisao e a aprovacao humanas ja ficaram registradas. Agora a diretoria pode publicar a versao no catalogo."; + } + if (humanGate.deactivation_action_available && humanGate.rollback_action_available) { + return `A versao esta ativa. Registre um parecer para desativar a publicacao ou executar rollback para v${escapeHtml(String(humanGate.rollback_target_version_number || "?"))}.`; + } + if (humanGate.deactivation_action_available) { + return "A versao esta ativa. Registre um parecer para desativar a publicacao ativa com trilha auditavel."; + } + return "As notas da decisao ficam persistidas na trilha administrativa da versao."; + } } function mountToolIntakePage(page) { diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index c6482b5..4b3d2ea 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -483,7 +483,10 @@ class AdminPanelToolsWebTests(unittest.TestCase): }, ) version_id = intake_response.json()["draft_preview"]["version_id"] - response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") + response = client.post( + f"/admin/panel/tools/review-queue/{version_id}/review", + json={"decision_notes": "Parecer inicial da diretoria para a revisao humana.", "reviewed_generated_code": True}, + ) finally: app.dependency_overrides.clear() @@ -521,6 +524,42 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(payload["items"][0]["gate"], "generation_pipeline_required") self.assertEqual(payload["items"][0]["version_number"], 1) + def test_panel_tools_review_detail_returns_generated_source_for_diretor_session(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [ + { + "name": "contrato_id", + "parameter_type": "string", + "description": "Identificador do contrato consultado.", + "required": True, + } + ], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + pipeline_response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + response = client.get(f"/admin/panel/tools/review-queue/{version_id}") + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(pipeline_response.status_code, 200) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["tool_name"], "emitir_resumo_locacao") + self.assertTrue(payload["human_gate"]["review_action_available"]) + self.assertIn("async def run", payload["generated_source_code"]) + self.assertEqual(len(payload["automated_validations"]), 4) + def test_panel_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: @@ -665,10 +704,19 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) version_id = intake_response.json()["draft_preview"]["version_id"] publish_before_approval = client.post(f"/admin/panel/tools/publications/{version_id}/publish") - review_before_pipeline = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") + review_before_pipeline = client.post( + f"/admin/panel/tools/review-queue/{version_id}/review", + json={"decision_notes": "Tentativa de revisao antes da pipeline.", "reviewed_generated_code": True}, + ) pipeline_response = client.post(f"/admin/panel/tools/pipeline/{version_id}/run") - review_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/review") - approve_response = client.post(f"/admin/panel/tools/review-queue/{version_id}/approve") + review_response = client.post( + f"/admin/panel/tools/review-queue/{version_id}/review", + json={"decision_notes": "Analisei o codigo completo gerado antes da validacao humana.", "reviewed_generated_code": True}, + ) + approve_response = client.post( + f"/admin/panel/tools/review-queue/{version_id}/approve", + json={"decision_notes": "Aprovacao formal da diretoria para seguir com a publicacao.", "reviewed_generated_code": True}, + ) pre_publications = client.get("/admin/panel/tools/publications") publish_response = client.post(f"/admin/panel/tools/publications/{version_id}/publish") final_publications = client.get("/admin/panel/tools/publications") @@ -714,5 +762,109 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(publication["parameters"][0]["name"], "contrato_id") + + def test_panel_tools_director_can_deactivate_active_publication(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + client.post(f"/admin/panel/tools/pipeline/{version_id}/run") + client.post( + f"/admin/panel/tools/review-queue/{version_id}/review", + json={"decision_notes": "Analisei o codigo completo antes da ativacao.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/panel/tools/review-queue/{version_id}/approve", + json={"decision_notes": "Aprovacao formal para disponibilizar a ferramenta."}, + ) + client.post(f"/admin/panel/tools/publications/{version_id}/publish") + deactivate_response = client.post( + f"/admin/panel/tools/publications/{version_id}/deactivate", + json={"decision_notes": "Desativacao controlada da ferramenta ativa apos teste concluido."}, + ) + publications_response = client.get("/admin/panel/tools/publications") + finally: + app.dependency_overrides.clear() + + self.assertEqual(deactivate_response.status_code, 200) + self.assertEqual(deactivate_response.json()["status"], "archived") + self.assertIsNone(deactivate_response.json()["queue_entry"]) + self.assertEqual(publications_response.status_code, 200) + self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in publications_response.json()["publications"]]) + + def test_panel_tools_director_can_rollback_active_publication(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + first_intake = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + ) + first_version_id = first_intake.json()["draft_preview"]["version_id"] + client.post(f"/admin/panel/tools/pipeline/{first_version_id}/run") + client.post( + f"/admin/panel/tools/review-queue/{first_version_id}/review", + json={"decision_notes": "Primeira revisao completa do codigo gerado.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/panel/tools/review-queue/{first_version_id}/approve", + json={"decision_notes": "Primeira aprovacao formal da diretoria."}, + ) + client.post(f"/admin/panel/tools/publications/{first_version_id}/publish") + + second_intake = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com mais contexto operacional para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos com filtros adicionais.", + "parameters": [], + }, + ) + second_version_id = second_intake.json()["draft_preview"]["version_id"] + client.post(f"/admin/panel/tools/pipeline/{second_version_id}/run") + client.post( + f"/admin/panel/tools/review-queue/{second_version_id}/review", + json={"decision_notes": "Segunda revisao completa do codigo gerado.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/panel/tools/review-queue/{second_version_id}/approve", + json={"decision_notes": "Segunda aprovacao formal da diretoria."}, + ) + client.post(f"/admin/panel/tools/publications/{second_version_id}/publish") + + rollback_response = client.post( + f"/admin/panel/tools/publications/{second_version_id}/rollback", + json={"decision_notes": "Rollback controlado para restaurar a versao anterior estavel."}, + ) + publications_response = client.get("/admin/panel/tools/publications") + finally: + app.dependency_overrides.clear() + + self.assertEqual(rollback_response.status_code, 200) + self.assertEqual(rollback_response.json()["status"], "active") + self.assertEqual(rollback_response.json()["version_id"], first_version_id) + publication = next(item for item in publications_response.json()["publications"] if item["tool_name"] == "emitir_resumo_locacao") + self.assertEqual(publication["version_id"], first_version_id) + self.assertTrue(publication["deactivation_action_available"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_tool_management_service.py b/tests/test_admin_tool_management_service.py index 433ea52..365fd97 100644 --- a/tests/test_admin_tool_management_service.py +++ b/tests/test_admin_tool_management_service.py @@ -944,6 +944,8 @@ class AdminToolManagementServiceTests(unittest.TestCase): reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR, + decision_notes="Aguardando a geracao controlada da funcao.", + reviewed_generated_code=True, ) def test_director_must_review_approve_and_publish_before_activation(self): @@ -979,12 +981,15 @@ class AdminToolManagementServiceTests(unittest.TestCase): reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR, + decision_notes="Analisei o codigo completo gerado e a estrutura esta aderente ao fluxo governado.", + reviewed_generated_code=True, ) approve_payload = self.service.approve_version( version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao formal registrada apos revisao tecnica e leitura integral do codigo.", ) publish_payload = self.service.publish_version( version_id, @@ -1033,6 +1038,61 @@ class AdminToolManagementServiceTests(unittest.TestCase): publisher_role=StaffRole.DIRETOR, ) + def test_build_review_detail_payload_exposes_generated_source_and_human_history(self): + intake_payload = self.service.create_draft_submission( + { + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + owner_staff_account_id=8, + owner_name="Operacao de Oficina", + ) + version_id = intake_payload["draft_preview"]["version_id"] + + self.service.run_generation_pipeline( + version_id, + runner_staff_account_id=8, + runner_name="Operacao de Oficina", + runner_role=StaffRole.COLABORADOR, + ) + self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Analise completa do codigo gerado antes da validacao humana.", + reviewed_generated_code=True, + ) + self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao formal da versao apos revisao humana detalhada.", + ) + + payload = self.service.build_review_detail_payload(version_id) + + self.assertEqual(payload["status"], ToolLifecycleStatus.APPROVED) + self.assertEqual(payload["human_gate"]["publication_action_available"], True) + self.assertIn("async def run", payload["generated_source_code"]) + self.assertEqual(len(payload["automated_validations"]), 4) + self.assertEqual(len(payload["decision_history"]), 2) + self.assertEqual(payload["decision_history"][0]["action_key"], ToolArtifactKind.DIRECTOR_REVIEW.value) + self.assertTrue(payload["decision_history"][0]["reviewed_generated_code"]) + self.assertIn("aprovacao formal", payload["decision_history"][1]["decision_notes"].lower()) + def test_publishing_new_version_archives_previous_active_version(self): first_intake = self.service.create_draft_submission( { @@ -1048,8 +1108,21 @@ class AdminToolManagementServiceTests(unittest.TestCase): ) first_version_id = first_intake["draft_preview"]["version_id"] self.service.run_generation_pipeline(first_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) - self.service.review_version(first_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) - self.service.approve_version(first_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) + self.service.review_version( + first_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Primeira versao revisada com leitura integral do codigo gerado.", + reviewed_generated_code=True, + ) + self.service.approve_version( + first_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Primeira versao aprovada para ativacao controlada.", + ) self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) second_intake = self.service.create_draft_submission( @@ -1066,8 +1139,21 @@ class AdminToolManagementServiceTests(unittest.TestCase): ) second_version_id = second_intake["draft_preview"]["version_id"] self.service.run_generation_pipeline(second_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) - self.service.review_version(second_version_id, reviewer_staff_account_id=99, reviewer_name="Diretoria", reviewer_role=StaffRole.DIRETOR) - self.service.approve_version(second_version_id, approver_staff_account_id=99, approver_name="Diretoria", approver_role=StaffRole.DIRETOR) + self.service.review_version( + second_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Nova versao revisada com comparativo do codigo completo gerado.", + reviewed_generated_code=True, + ) + self.service.approve_version( + second_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Nova versao aprovada para substituir a publicacao anterior.", + ) self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) versions_by_number = {version.version_number: version for version in self.version_repository.versions} @@ -1078,6 +1164,148 @@ class AdminToolManagementServiceTests(unittest.TestCase): self.assertEqual(metadata_by_number[2].status, ToolLifecycleStatus.ACTIVE) + + def test_deactivating_active_version_archives_publication_and_removes_tool_from_catalog(self): + intake = self.service.create_draft_submission( + { + "domain": "locacao", + "tool_name": "emitir_resumo_locacao", + "display_name": "Emitir resumo de locacao", + "description": "Resume contratos de locacao com filtros operacionais para o time interno.", + "business_goal": "Dar visibilidade rapida aos contratos e aos principais dados da locacao.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + version_id = intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) + self.service.review_version( + version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Analisei a versao ativa antes da desativacao controlada.", + reviewed_generated_code=True, + ) + self.service.approve_version( + version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Aprovacao formal para ativar e depois validar a desativacao controlada.", + ) + self.service.publish_version(version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + payload = self.service.deactivate_version( + version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="Desativacao controlada da tool apos encerramento temporario do uso.", + ) + + self.assertEqual(payload["status"], ToolLifecycleStatus.ARCHIVED) + self.assertIsNone(payload["queue_entry"]) + detail = self.service.build_review_detail_payload(version_id) + self.assertEqual(detail["status"], ToolLifecycleStatus.ARCHIVED) + self.assertFalse(detail["human_gate"]["deactivation_action_available"]) + self.assertFalse(detail["human_gate"]["rollback_action_available"]) + self.assertEqual(detail["decision_history"][-1]["action_key"], ToolArtifactKind.PUBLICATION_DEACTIVATION.value) + publications = self.service.build_publications_payload() + self.assertNotIn("emitir_resumo_locacao", [item["tool_name"] for item in publications["publications"]]) + + def test_rollback_restores_latest_archived_version_into_active_catalog(self): + first_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado para acompanhamento administrativo.", + "business_goal": "Dar visibilidade ao time interno sobre os principais gargalos do funil.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + first_version_id = first_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(first_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) + self.service.review_version( + first_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Primeira versao revisada antes da futura ativacao controlada.", + reviewed_generated_code=True, + ) + self.service.approve_version( + first_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Primeira versao aprovada para publicacao inicial.", + ) + self.service.publish_version(first_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + second_intake = self.service.create_draft_submission( + { + "domain": "vendas", + "tool_name": "consultar_funil_comercial", + "display_name": "Consultar funil comercial", + "description": "Consulta o funil comercial consolidado com campos adicionais para acompanhamento administrativo.", + "business_goal": "Dar visibilidade ao time interno sobre gargalos, volume e conversao do funil.", + "parameters": [], + }, + owner_staff_account_id=7, + owner_name="Equipe Interna", + ) + second_version_id = second_intake["draft_preview"]["version_id"] + self.service.run_generation_pipeline(second_version_id, runner_staff_account_id=7, runner_name="Equipe Interna", runner_role=StaffRole.COLABORADOR) + self.service.review_version( + second_version_id, + reviewer_staff_account_id=99, + reviewer_name="Diretoria", + reviewer_role=StaffRole.DIRETOR, + decision_notes="Nova versao revisada com leitura integral antes da substituicao.", + reviewed_generated_code=True, + ) + self.service.approve_version( + second_version_id, + approver_staff_account_id=99, + approver_name="Diretoria", + approver_role=StaffRole.DIRETOR, + decision_notes="Nova versao aprovada para substituir a publicacao anterior.", + ) + self.service.publish_version(second_version_id, publisher_staff_account_id=99, publisher_name="Diretoria", publisher_role=StaffRole.DIRETOR) + + active_detail = self.service.build_review_detail_payload(second_version_id) + self.assertTrue(active_detail["human_gate"]["deactivation_action_available"]) + self.assertTrue(active_detail["human_gate"]["rollback_action_available"]) + self.assertEqual(active_detail["human_gate"]["rollback_target_version_number"], 1) + + payload = self.service.rollback_version( + second_version_id, + actor_staff_account_id=99, + actor_name="Diretoria", + actor_role=StaffRole.DIRETOR, + decision_notes="Rollback controlado para restaurar a versao anterior mais estavel.", + ) + + versions_by_number = {version.version_number: version for version in self.version_repository.versions} + metadata_by_number = {metadata.version_number: metadata for metadata in self.metadata_repository.metadata_entries} + self.assertEqual(payload["status"], ToolLifecycleStatus.ACTIVE) + self.assertEqual(payload["version_id"], first_version_id) + self.assertEqual(versions_by_number[1].status, ToolLifecycleStatus.ACTIVE) + self.assertEqual(metadata_by_number[1].status, ToolLifecycleStatus.ACTIVE) + self.assertEqual(versions_by_number[2].status, ToolLifecycleStatus.ARCHIVED) + self.assertEqual(metadata_by_number[2].status, ToolLifecycleStatus.ARCHIVED) + restored_detail = self.service.build_review_detail_payload(first_version_id) + self.assertEqual(restored_detail["decision_history"][-1]["action_key"], ToolArtifactKind.PUBLICATION_ROLLBACK.value) + publications = self.service.build_publications_payload() + restored_publication = next(item for item in publications["publications"] if item["tool_name"] == "consultar_funil_comercial") + self.assertEqual(restored_publication["version_id"], first_version_id) + self.assertTrue(restored_publication["deactivation_action_available"]) + class AdminToolManagementTransactionalPersistenceTests(unittest.TestCase): def setUp(self): self.engine = create_engine("sqlite:///:memory:") diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index dd56aa4..08b357c 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -504,6 +504,10 @@ class AdminToolsWebTests(unittest.TestCase): response = client.post( f"/admin/tools/review-queue/{version_id}/review", headers={"Authorization": "Bearer token"}, + json={ + "decision_notes": "Parecer inicial da diretoria para a revisao humana.", + "reviewed_generated_code": True, + }, ) finally: app.dependency_overrides.clear() @@ -544,6 +548,49 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["items"][0]["version_number"], 1) self.assertIn("approved", payload["supported_statuses"]) + def test_tools_review_detail_returns_generated_source_for_diretor(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [ + { + "name": "placa", + "parameter_type": "string", + "description": "Placa usada na busca da revisao.", + "required": True, + } + ], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + pipeline_response = client.post( + f"/admin/tools/pipeline/{version_id}/run", + headers={"Authorization": "Bearer token"}, + ) + response = client.get( + f"/admin/tools/review-queue/{version_id}", + headers={"Authorization": "Bearer token"}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(intake_response.status_code, 200) + self.assertEqual(pipeline_response.status_code, 200) + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["tool_name"], "consultar_revisao_aberta") + self.assertTrue(payload["human_gate"]["review_action_available"]) + self.assertIn("async def run", payload["generated_source_code"]) + self.assertEqual(len(payload["automated_validations"]), 4) + def test_tools_collaborator_can_run_generation_pipeline_after_manual_intake(self): client, app, _, _, _, _ = self._build_client_with_role(StaffRole.COLABORADOR) try: @@ -707,6 +754,10 @@ class AdminToolsWebTests(unittest.TestCase): review_before_pipeline = client.post( f"/admin/tools/review-queue/{version_id}/review", headers={"Authorization": "Bearer token"}, + json={ + "decision_notes": "Tentativa de revisao antes da pipeline.", + "reviewed_generated_code": True, + }, ) pipeline_response = client.post( f"/admin/tools/pipeline/{version_id}/run", @@ -715,10 +766,18 @@ class AdminToolsWebTests(unittest.TestCase): review_response = client.post( f"/admin/tools/review-queue/{version_id}/review", headers={"Authorization": "Bearer token"}, + json={ + "decision_notes": "Analisei o codigo completo gerado antes da validacao humana.", + "reviewed_generated_code": True, + }, ) approve_response = client.post( f"/admin/tools/review-queue/{version_id}/approve", headers={"Authorization": "Bearer token"}, + json={ + "decision_notes": "Aprovacao formal da diretoria para seguir com a publicacao.", + "reviewed_generated_code": True, + }, ) pre_publications = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) publish_response = client.post( @@ -769,5 +828,120 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(publication["parameters"][0]["parameter_type"], "string") + + def test_tools_director_can_deactivate_active_publication(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + intake_response = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + ) + version_id = intake_response.json()["draft_preview"]["version_id"] + client.post(f"/admin/tools/pipeline/{version_id}/run", headers={"Authorization": "Bearer token"}) + client.post( + f"/admin/tools/review-queue/{version_id}/review", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Analisei o codigo completo antes da ativacao.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/tools/review-queue/{version_id}/approve", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Aprovacao formal para disponibilizar a ferramenta."}, + ) + client.post(f"/admin/tools/publications/{version_id}/publish", headers={"Authorization": "Bearer token"}) + deactivate_response = client.post( + f"/admin/tools/publications/{version_id}/deactivate", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Desativacao controlada da ferramenta ativa apos teste concluido."}, + ) + publications_response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(deactivate_response.status_code, 200) + self.assertEqual(deactivate_response.json()["status"], "archived") + self.assertIsNone(deactivate_response.json()["queue_entry"]) + self.assertEqual(publications_response.status_code, 200) + self.assertNotIn("consultar_revisao_aberta", [item["tool_name"] for item in publications_response.json()["publications"]]) + + def test_tools_director_can_rollback_active_publication(self): + client, app, _, _, _, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + first_intake = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com filtros administrativos para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com mais contexto operacional.", + "parameters": [], + }, + ) + first_version_id = first_intake.json()["draft_preview"]["version_id"] + client.post(f"/admin/tools/pipeline/{first_version_id}/run", headers={"Authorization": "Bearer token"}) + client.post( + f"/admin/tools/review-queue/{first_version_id}/review", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Primeira revisao completa do codigo gerado.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/tools/review-queue/{first_version_id}/approve", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Primeira aprovacao formal da diretoria."}, + ) + client.post(f"/admin/tools/publications/{first_version_id}/publish", headers={"Authorization": "Bearer token"}) + + second_intake = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "revisao", + "tool_name": "consultar_revisao_aberta", + "display_name": "Consultar revisao aberta", + "description": "Consulta revisoes abertas com mais contexto operacional para a oficina.", + "business_goal": "Ajudar o time a localizar revisoes abertas com filtros extras.", + "parameters": [], + }, + ) + second_version_id = second_intake.json()["draft_preview"]["version_id"] + client.post(f"/admin/tools/pipeline/{second_version_id}/run", headers={"Authorization": "Bearer token"}) + client.post( + f"/admin/tools/review-queue/{second_version_id}/review", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Segunda revisao completa do codigo gerado.", "reviewed_generated_code": True}, + ) + client.post( + f"/admin/tools/review-queue/{second_version_id}/approve", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Segunda aprovacao formal da diretoria."}, + ) + client.post(f"/admin/tools/publications/{second_version_id}/publish", headers={"Authorization": "Bearer token"}) + + rollback_response = client.post( + f"/admin/tools/publications/{second_version_id}/rollback", + headers={"Authorization": "Bearer token"}, + json={"decision_notes": "Rollback controlado para restaurar a versao anterior estavel."}, + ) + publications_response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(rollback_response.status_code, 200) + self.assertEqual(rollback_response.json()["status"], "active") + self.assertEqual(rollback_response.json()["version_id"], first_version_id) + publication = next(item for item in publications_response.json()["publications"] if item["tool_name"] == "consultar_revisao_aberta") + self.assertEqual(publication["version_id"], first_version_id) + self.assertTrue(publication["deactivation_action_available"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_admin_view_bootstrap.py b/tests/test_admin_view_bootstrap.py index 44404e4..301a84e 100644 --- a/tests/test_admin_view_bootstrap.py +++ b/tests/test_admin_view_bootstrap.py @@ -158,6 +158,10 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn('data-contracts-endpoint="/panel/tools/contracts"', response.text) self.assertIn('data-review-queue-endpoint="/panel/tools/review-queue"', response.text) self.assertIn('data-publications-endpoint="/panel/tools/publications"', response.text) + self.assertIn('data-tool-review-code', response.text) + self.assertIn('data-tool-review-decision-notes', response.text) + self.assertIn('data-tool-review-action="deactivate"', response.text) + self.assertIn('data-tool-review-action="rollback"', response.text) self.assertNotIn("Abrir login administrativo", response.text) def test_collaborator_management_page_redirects_to_login_without_session(self):