diff --git a/admin_app/api/dependencies.py b/admin_app/api/dependencies.py index efd053c..04d24db 100644 --- a/admin_app/api/dependencies.py +++ b/admin_app/api/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -12,7 +12,7 @@ from admin_app.core import ( ) from admin_app.db.database import get_admin_db_session from admin_app.repositories import AuditLogRepository, StaffAccountRepository, StaffSessionRepository -from admin_app.services import AuditService, AuthService +from admin_app.services import AuditService, AuthService, CollaboratorManagementService from shared.contracts import AdminPermission, StaffRole, permissions_for_role, role_has_permission, role_includes bearer_scheme = HTTPBearer(auto_error=False) @@ -65,6 +65,18 @@ def get_auth_service( ) +def get_collaborator_management_service( + account_repository: StaffAccountRepository = Depends(get_staff_account_repository), + security_service: AdminSecurityService = Depends(get_security_service), + audit_service: AuditService = Depends(get_audit_service), +) -> CollaboratorManagementService: + return CollaboratorManagementService( + account_repository=account_repository, + security_service=security_service, + audit_service=audit_service, + ) + + def get_current_staff_context( credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), auth_service: AuthService = Depends(get_auth_service), @@ -198,3 +210,4 @@ def get_current_staff_permissions( current_staff: AuthenticatedStaffPrincipal = Depends(get_current_staff_principal), ) -> tuple[str, ...]: return tuple(permission.value for permission in permissions_for_role(current_staff.role)) + diff --git a/admin_app/api/router.py b/admin_app/api/router.py index edae7a0..298a32e 100644 --- a/admin_app/api/router.py +++ b/admin_app/api/router.py @@ -2,16 +2,19 @@ from fastapi import APIRouter from admin_app.api.routes.audit import router as audit_router from admin_app.api.routes.auth import router as auth_router +from admin_app.api.routes.collaborators import router as collaborators_router from admin_app.api.routes.panel_auth import router as panel_auth_router +from admin_app.api.routes.panel_collaborators import router as panel_collaborators_router from admin_app.api.routes.panel_tools import router as panel_tools_router from admin_app.api.routes.system import router as system_router from admin_app.api.routes.tools import router as tools_router -# Agrega as rotas do servico administrativo. api_router = APIRouter() api_router.include_router(auth_router) api_router.include_router(panel_auth_router) +api_router.include_router(panel_collaborators_router) api_router.include_router(panel_tools_router) api_router.include_router(system_router) +api_router.include_router(collaborators_router) api_router.include_router(tools_router) api_router.include_router(audit_router) diff --git a/admin_app/api/routes/collaborators.py b/admin_app/api/routes/collaborators.py new file mode 100644 index 0000000..1eda0bf --- /dev/null +++ b/admin_app/api/routes/collaborators.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from admin_app.api.dependencies import ( + get_collaborator_management_service, + require_admin_permission, +) +from admin_app.api.schemas import ( + AdminCollaboratorCreateRequest, + AdminCollaboratorCreateResponse, + AdminCollaboratorListResponse, + AdminCollaboratorStatusUpdateRequest, + AdminCollaboratorStatusUpdateResponse, + AdminCollaboratorSummaryResponse, +) +from admin_app.core import AuthenticatedStaffPrincipal +from admin_app.services import CollaboratorManagementService +from shared.contracts import AdminPermission + +router = APIRouter(prefix="/colaboradores", tags=["colaboradores"]) + + +@router.get("", response_model=AdminCollaboratorListResponse) +def list_collaborators( + _: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + payload = service.list_collaborators() + return AdminCollaboratorListResponse( + service="orquestrador-admin", + total=payload["total"], + active_count=payload["active_count"], + inactive_count=payload["inactive_count"], + collaborators=[AdminCollaboratorSummaryResponse(**account) for account in payload["accounts"]], + ) + + +@router.post("", response_model=AdminCollaboratorCreateResponse, status_code=status.HTTP_201_CREATED) +def create_collaborator( + collaborator: AdminCollaboratorCreateRequest, + request: Request, + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + try: + payload = service.create_collaborator( + email=collaborator.email, + display_name=collaborator.display_name, + password=collaborator.password, + is_active=collaborator.is_active, + actor_staff_account_id=current_staff.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + except ValueError as exc: + detail = str(exc) + status_code = status.HTTP_409_CONFLICT if detail.startswith("Ja existe") else status.HTTP_422_UNPROCESSABLE_CONTENT + raise HTTPException(status_code=status_code, detail=detail) from exc + + return AdminCollaboratorCreateResponse( + service="orquestrador-admin", + message="Colaborador administrativo criado com sucesso.", + collaborator=AdminCollaboratorSummaryResponse(**payload), + ) + + +@router.patch("/{collaborator_id}/status", response_model=AdminCollaboratorStatusUpdateResponse) +def update_collaborator_status( + collaborator_id: int, + payload: AdminCollaboratorStatusUpdateRequest, + request: Request, + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + try: + updated = service.update_collaborator_status( + collaborator_id=collaborator_id, + is_active=payload.is_active, + actor_staff_account_id=current_staff.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + action_label = "ativado" if updated["is_active"] else "desativado" + return AdminCollaboratorStatusUpdateResponse( + service="orquestrador-admin", + message=f"Colaborador administrativo {action_label} com sucesso.", + collaborator=AdminCollaboratorSummaryResponse(**updated), + ) diff --git a/admin_app/api/routes/panel_collaborators.py b/admin_app/api/routes/panel_collaborators.py new file mode 100644 index 0000000..682a2bc --- /dev/null +++ b/admin_app/api/routes/panel_collaborators.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, status + +from admin_app.api.dependencies import ( + get_collaborator_management_service, + require_panel_admin_permission, +) +from admin_app.api.schemas import ( + AdminCollaboratorCreateRequest, + AdminCollaboratorCreateResponse, + AdminCollaboratorListResponse, + AdminCollaboratorStatusUpdateRequest, + AdminCollaboratorStatusUpdateResponse, + AdminCollaboratorSummaryResponse, +) +from admin_app.core import AuthenticatedStaffPrincipal +from admin_app.services import CollaboratorManagementService +from shared.contracts import AdminPermission + +router = APIRouter(prefix="/panel/colaboradores", tags=["panel-colaboradores"]) + + +@router.get("", response_model=AdminCollaboratorListResponse) +def panel_list_collaborators( + _: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + payload = service.list_collaborators() + return AdminCollaboratorListResponse( + service="orquestrador-admin", + total=payload["total"], + active_count=payload["active_count"], + inactive_count=payload["inactive_count"], + collaborators=[AdminCollaboratorSummaryResponse(**account) for account in payload["accounts"]], + ) + + +@router.post("", response_model=AdminCollaboratorCreateResponse, status_code=status.HTTP_201_CREATED) +def panel_create_collaborator( + collaborator: AdminCollaboratorCreateRequest, + request: Request, + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + try: + payload = service.create_collaborator( + email=collaborator.email, + display_name=collaborator.display_name, + password=collaborator.password, + is_active=collaborator.is_active, + actor_staff_account_id=current_staff.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + except ValueError as exc: + detail = str(exc) + status_code = status.HTTP_409_CONFLICT if detail.startswith("Ja existe") else status.HTTP_422_UNPROCESSABLE_CONTENT + raise HTTPException(status_code=status_code, detail=detail) from exc + + return AdminCollaboratorCreateResponse( + service="orquestrador-admin", + message="Colaborador administrativo criado com sucesso.", + collaborator=AdminCollaboratorSummaryResponse(**payload), + ) + + +@router.patch("/{collaborator_id}/status", response_model=AdminCollaboratorStatusUpdateResponse) +def panel_update_collaborator_status( + collaborator_id: int, + payload: AdminCollaboratorStatusUpdateRequest, + request: Request, + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.MANAGE_STAFF_ACCOUNTS) + ), + service: CollaboratorManagementService = Depends(get_collaborator_management_service), +): + try: + updated = service.update_collaborator_status( + collaborator_id=collaborator_id, + is_active=payload.is_active, + actor_staff_account_id=current_staff.id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + except LookupError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + action_label = "ativado" if updated["is_active"] else "desativado" + return AdminCollaboratorStatusUpdateResponse( + service="orquestrador-admin", + message=f"Colaborador administrativo {action_label} com sucesso.", + collaborator=AdminCollaboratorSummaryResponse(**updated), + ) diff --git a/admin_app/api/routes/panel_tools.py b/admin_app/api/routes/panel_tools.py index 8a396b7..100fb33 100644 --- a/admin_app/api/routes/panel_tools.py +++ b/admin_app/api/routes/panel_tools.py @@ -1,8 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from admin_app.api.dependencies import get_settings, require_panel_admin_permission from admin_app.api.schemas import ( AdminToolContractsResponse, + AdminToolDraftIntakeRequest, + AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, @@ -86,6 +88,39 @@ def panel_tool_drafts( ) +@router.post( + "/drafts/intake", + response_model=AdminToolDraftIntakeResponse, +) +def panel_tool_draft_intake( + draft: AdminToolDraftIntakeRequest, + settings: AdminSettings = Depends(get_settings), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_panel_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) + ), +): + service = _build_service(settings) + try: + payload = service.preview_draft_submission( + draft.model_dump(), + owner_name=current_staff.display_name, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + return AdminToolDraftIntakeResponse( + service="orquestrador-admin", + storage_status=payload["storage_status"], + message=payload["message"], + draft_preview=payload["draft_preview"], + warnings=payload["warnings"], + next_steps=payload["next_steps"], + ) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -143,6 +178,13 @@ def _build_panel_actions(settings: AdminSettings) -> list[AdminToolManagementAct required_permission=AdminPermission.MANAGE_TOOL_DRAFTS, description="Base contratual para a tela de revisao e aprovacao.", ), + AdminToolManagementActionResponse( + key="draft_intake", + label="Pre-cadastro web de tool", + href=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/drafts/intake"), + required_permission=AdminPermission.MANAGE_TOOL_DRAFTS, + description="Valida o formulario real de cadastro diretamente na sessao web do painel.", + ), AdminToolManagementActionResponse( key="review_queue", label="Fila web de revisao", @@ -168,3 +210,4 @@ def _build_prefixed_path(api_prefix: str, path: str) -> str: if normalized_path == "/": return f"{normalized_prefix}/" return f"{normalized_prefix}{normalized_path}" + diff --git a/admin_app/api/routes/tools.py b/admin_app/api/routes/tools.py index 1efd938..889cfcd 100644 --- a/admin_app/api/routes/tools.py +++ b/admin_app/api/routes/tools.py @@ -1,8 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status -from admin_app.api.dependencies import get_settings, require_admin_permission +from admin_app.api.dependencies import get_current_staff_principal, get_settings, require_admin_permission from admin_app.api.schemas import ( AdminToolContractsResponse, + AdminToolDraftIntakeRequest, + AdminToolDraftIntakeResponse, AdminToolDraftListResponse, AdminToolManagementActionResponse, AdminToolOverviewResponse, @@ -86,6 +88,39 @@ def tool_drafts( ) +@router.post( + "/drafts/intake", + response_model=AdminToolDraftIntakeResponse, +) +def tool_draft_intake( + draft: AdminToolDraftIntakeRequest, + settings: AdminSettings = Depends(get_settings), + current_staff: AuthenticatedStaffPrincipal = Depends( + require_admin_permission(AdminPermission.MANAGE_TOOL_DRAFTS) + ), +): + service = _build_service(settings) + try: + payload = service.preview_draft_submission( + draft.model_dump(), + owner_name=current_staff.display_name, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + return AdminToolDraftIntakeResponse( + service="orquestrador-admin", + storage_status=payload["storage_status"], + message=payload["message"], + draft_preview=payload["draft_preview"], + warnings=payload["warnings"], + next_steps=payload["next_steps"], + ) + + @router.get( "/review-queue", response_model=AdminToolReviewQueueResponse, @@ -150,6 +185,13 @@ def _build_actions(settings: AdminSettings) -> list[AdminToolManagementActionRes required_permission=AdminPermission.MANAGE_TOOL_DRAFTS, description="Base do cadastro de novas tools e estados vazios da fase atual.", ), + AdminToolManagementActionResponse( + key="draft_intake", + label="Pre-cadastro de tool", + href=_build_prefixed_path(settings.admin_api_prefix, "/tools/drafts/intake"), + required_permission=AdminPermission.MANAGE_TOOL_DRAFTS, + description="Valida o formulario real de cadastro antes da persistencia definitiva.", + ), AdminToolManagementActionResponse( key="review_queue", label="Fila de revisao", diff --git a/admin_app/api/schemas.py b/admin_app/api/schemas.py index bf23ccf..300eff3 100644 --- a/admin_app/api/schemas.py +++ b/admin_app/api/schemas.py @@ -275,3 +275,128 @@ class AdminToolPublicationListResponse(BaseModel): source: str target_service: ServiceName publications: list[AdminToolPublicationSummaryResponse] + +class AdminToolDraftIntakeParameterRequest(BaseModel): + name: str = Field(min_length=1, max_length=64) + parameter_type: ToolParameterType + description: str = Field(min_length=1, max_length=180) + required: bool = True + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + return value.strip().lower() + + @field_validator("description") + @classmethod + def normalize_description(cls, value: str) -> str: + return value.strip() + + +class AdminToolDraftIntakeRequest(BaseModel): + tool_name: str = Field(min_length=3, max_length=64) + display_name: str = Field(min_length=4, max_length=120) + domain: str = Field(min_length=3, max_length=40) + description: str = Field(min_length=16, max_length=280) + business_goal: str = Field(min_length=12, max_length=280) + parameters: list[AdminToolDraftIntakeParameterRequest] = Field(default_factory=list, max_length=10) + + @field_validator("tool_name") + @classmethod + def normalize_tool_name(cls, value: str) -> str: + return value.strip().lower() + + @field_validator("display_name", "description", "business_goal") + @classmethod + def strip_text_fields(cls, value: str) -> str: + return value.strip() + + @field_validator("domain") + @classmethod + def normalize_domain(cls, value: str) -> str: + return value.strip().lower() + + +class AdminToolDraftIntakePreviewParameterResponse(BaseModel): + name: str + parameter_type: ToolParameterType + description: str + required: bool + + +class AdminToolDraftIntakePreviewResponse(BaseModel): + draft_id: str + tool_name: str + display_name: str + domain: str + status: ToolLifecycleStatus + summary: str + business_goal: str + parameter_count: int + required_parameter_count: int + requires_director_approval: bool + owner_name: str | None = None + parameters: list[AdminToolDraftIntakePreviewParameterResponse] + + +class AdminToolDraftIntakeResponse(BaseModel): + service: str + storage_status: str + message: str + draft_preview: AdminToolDraftIntakePreviewResponse + warnings: list[str] + next_steps: list[str] + +class AdminCollaboratorSummaryResponse(BaseModel): + id: int + email: str + display_name: str + role: StaffRole + is_active: bool + last_login_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class AdminCollaboratorListResponse(BaseModel): + service: str + total: int + active_count: int + inactive_count: int + collaborators: list[AdminCollaboratorSummaryResponse] + + +class AdminCollaboratorCreateRequest(BaseModel): + email: str + display_name: str = Field(min_length=3, max_length=150) + password: str = Field(min_length=1) + is_active: bool = True + + @field_validator("email") + @classmethod + def normalize_email(cls, value: str) -> str: + normalized = value.strip().lower() + if "@" not in normalized or normalized.startswith("@") or normalized.endswith("@"): + raise ValueError("email must be a valid administrative login") + return normalized + + @field_validator("display_name") + @classmethod + def normalize_display_name(cls, value: str) -> str: + return value.strip() + + +class AdminCollaboratorCreateResponse(BaseModel): + service: str + message: str + collaborator: AdminCollaboratorSummaryResponse + + +class AdminCollaboratorStatusUpdateRequest(BaseModel): + is_active: bool + + +class AdminCollaboratorStatusUpdateResponse(BaseModel): + service: str + message: str + collaborator: AdminCollaboratorSummaryResponse diff --git a/admin_app/core/security.py b/admin_app/core/security.py index 66abc30..9053012 100644 --- a/admin_app/core/security.py +++ b/admin_app/core/security.py @@ -7,10 +7,10 @@ import json import secrets from datetime import datetime, timedelta, timezone -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from admin_app.core.settings import AdminSettings -from shared.contracts import StaffRole +from shared.contracts import StaffRole, normalize_staff_role class AdminPasswordPolicy(BaseModel): @@ -52,6 +52,11 @@ class AuthenticatedStaffPrincipal(BaseModel): role: StaffRole is_active: bool + @field_validator("role", mode="before") + @classmethod + def normalize_role(cls, value): + return normalize_staff_role(value) + class AuthenticatedStaffContext(BaseModel): principal: AuthenticatedStaffPrincipal @@ -68,6 +73,11 @@ class AdminAccessTokenClaims(BaseModel): iat: int exp: int + @field_validator("role", mode="before") + @classmethod + def normalize_role(cls, value): + return normalize_staff_role(value) + class AdminAuthenticatedSession(BaseModel): session_id: int @@ -104,7 +114,7 @@ class AdminSecurityService: enabled=self.settings.admin_bootstrap_enabled, email=self.settings.admin_bootstrap_email, display_name=self.settings.admin_bootstrap_display_name, - role=str(self.settings.admin_bootstrap_role or "admin"), + role=normalize_staff_role(self.settings.admin_bootstrap_role or StaffRole.DIRETOR.value).value, password_configured=bool(self.settings.admin_bootstrap_password), ), ) diff --git a/admin_app/core/settings.py b/admin_app/core/settings.py index 5c50a1e..7bea7b4 100644 --- a/admin_app/core/settings.py +++ b/admin_app/core/settings.py @@ -41,7 +41,7 @@ class AdminSettings(BaseSettings): admin_bootstrap_email: str | None = None admin_bootstrap_display_name: str | None = None admin_bootstrap_password: str | None = None - admin_bootstrap_role: str = "admin" + admin_bootstrap_role: str = "diretor" @field_validator("admin_debug", mode="before") @classmethod @@ -122,3 +122,4 @@ class AdminSettings(BaseSettings): @lru_cache(maxsize=1) def get_admin_settings() -> AdminSettings: return AdminSettings() + diff --git a/admin_app/db/models/staff_account.py b/admin_app/db/models/staff_account.py index 928a97c..3d907db 100644 --- a/admin_app/db/models/staff_account.py +++ b/admin_app/db/models/staff_account.py @@ -2,17 +2,34 @@ from __future__ import annotations from datetime import datetime -from sqlalchemy import Boolean, DateTime, Enum as SAEnum, Integer, String +from sqlalchemy import Boolean, DateTime, Integer, String from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import TypeDecorator from admin_app.db.models.base import AdminTimestampedModel -from shared.contracts import StaffRole +from shared.contracts import StaffRole, normalize_staff_role # Modelo da conta administrativa -# Ele representa o usuário interno do painel +# Ele representa o usuario interno do painel. -def _staff_role_values(enum_cls) -> list[str]: - return [role.value for role in enum_cls] + +class StaffRoleType(TypeDecorator): + impl = String(32) + cache_ok = True + + @property + def python_type(self): + return StaffRole + + def process_bind_param(self, value, dialect): + if value is None: + return None + return normalize_staff_role(value).value + + def process_result_value(self, value, dialect): + if value is None: + return None + return normalize_staff_role(value) class StaffAccount(AdminTimestampedModel): @@ -23,14 +40,9 @@ class StaffAccount(AdminTimestampedModel): display_name: Mapped[str] = mapped_column(String(150), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[StaffRole] = mapped_column( - SAEnum( - StaffRole, - native_enum=False, - values_callable=_staff_role_values, - length=32, - ), + StaffRoleType(), nullable=False, - default=StaffRole.VIEWER, + default=StaffRole.COLABORADOR, ) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/admin_app/repositories/staff_account_repository.py b/admin_app/repositories/staff_account_repository.py index 77c9616..6d21704 100644 --- a/admin_app/repositories/staff_account_repository.py +++ b/admin_app/repositories/staff_account_repository.py @@ -1,10 +1,9 @@ from sqlalchemy import select -from sqlalchemy.orm import Session from admin_app.db.models import StaffAccount from admin_app.repositories.base_repository import BaseRepository +from shared.contracts import StaffRole -# encapsula o acesso a StaffAccount (busca por e-mail e id) class StaffAccountRepository(BaseRepository): def get_by_email(self, email: str) -> StaffAccount | None: @@ -15,8 +14,40 @@ class StaffAccountRepository(BaseRepository): statement = select(StaffAccount).where(StaffAccount.id == staff_account_id) return self.db.execute(statement).scalar_one_or_none() - def update_last_login(self, staff_account: StaffAccount) -> StaffAccount: + def list_by_role(self, role: StaffRole) -> list[StaffAccount]: + statement = ( + select(StaffAccount) + .where(StaffAccount.role == role) + .order_by(StaffAccount.display_name.asc(), StaffAccount.email.asc()) + ) + return list(self.db.execute(statement).scalars().all()) + + def create( + self, + *, + email: str, + display_name: str, + password_hash: str, + role: StaffRole, + is_active: bool, + ) -> StaffAccount: + staff_account = StaffAccount( + email=email, + display_name=display_name, + password_hash=password_hash, + role=role, + is_active=is_active, + ) self.db.add(staff_account) self.db.commit() self.db.refresh(staff_account) return staff_account + + def save(self, staff_account: StaffAccount) -> StaffAccount: + self.db.add(staff_account) + self.db.commit() + self.db.refresh(staff_account) + return staff_account + + def update_last_login(self, staff_account: StaffAccount) -> StaffAccount: + return self.save(staff_account) diff --git a/admin_app/services/__init__.py b/admin_app/services/__init__.py index ea7b64e..8e3ef70 100644 --- a/admin_app/services/__init__.py +++ b/admin_app/services/__init__.py @@ -4,6 +4,7 @@ from admin_app.services.audit_service import ( AuditService, ) from admin_app.services.auth_service import AuthService +from admin_app.services.collaborator_management_service import CollaboratorManagementService from admin_app.services.system_service import SystemService from admin_app.services.tool_management_service import ToolManagementService @@ -12,6 +13,7 @@ __all__ = [ "AdminAuditOutcome", "AuditService", "AuthService", + "CollaboratorManagementService", "SystemService", "ToolManagementService", ] diff --git a/admin_app/services/audit_service.py b/admin_app/services/audit_service.py index d63ff69..0a451f4 100644 --- a/admin_app/services/audit_service.py +++ b/admin_app/services/audit_service.py @@ -8,6 +8,8 @@ class AdminAuditEventType(str, Enum): STAFF_LOGIN_SUCCEEDED = "staff.login.succeeded" STAFF_LOGIN_FAILED = "staff.login.failed" STAFF_LOGOUT_SUCCEEDED = "staff.logout.succeeded" + STAFF_ACCOUNT_CREATED = "staff.account.created" + STAFF_ACCOUNT_STATUS_UPDATED = "staff.account.status.updated" TOOL_APPROVAL_RECORDED = "tool.approval.recorded" TOOL_PUBLICATION_RECORDED = "tool.publication.recorded" @@ -107,6 +109,56 @@ class AuditService: user_agent=user_agent, ) + def record_staff_account_created( + self, + *, + actor_staff_account_id: int, + created_staff_account_id: int, + email: str, + role: str, + ip_address: str | None, + user_agent: str | None, + ) -> AuditLog: + return self.record_event( + actor_staff_account_id=actor_staff_account_id, + event_type=AdminAuditEventType.STAFF_ACCOUNT_CREATED, + resource_type="staff_account", + resource_id=str(created_staff_account_id), + outcome=AdminAuditOutcome.SUCCESS, + message="Conta administrativa de colaborador criada.", + payload_json={ + "created_staff_account_id": created_staff_account_id, + "email": email, + "role": role, + }, + ip_address=ip_address, + user_agent=user_agent, + ) + + def record_staff_account_status_updated( + self, + *, + actor_staff_account_id: int, + target_staff_account_id: int, + is_active: bool, + ip_address: str | None, + user_agent: str | None, + ) -> AuditLog: + return self.record_event( + actor_staff_account_id=actor_staff_account_id, + event_type=AdminAuditEventType.STAFF_ACCOUNT_STATUS_UPDATED, + resource_type="staff_account", + resource_id=str(target_staff_account_id), + outcome=AdminAuditOutcome.SUCCESS, + message="Status de colaborador administrativo atualizado.", + payload_json={ + "target_staff_account_id": target_staff_account_id, + "is_active": is_active, + }, + ip_address=ip_address, + user_agent=user_agent, + ) + def record_tool_approval( self, *, diff --git a/admin_app/services/collaborator_management_service.py b/admin_app/services/collaborator_management_service.py new file mode 100644 index 0000000..2508a80 --- /dev/null +++ b/admin_app/services/collaborator_management_service.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from admin_app.core import AdminSecurityService +from admin_app.db.models import StaffAccount +from admin_app.repositories import StaffAccountRepository +from admin_app.services.audit_service import AuditService +from shared.contracts import StaffRole + + +class CollaboratorManagementService: + def __init__( + self, + *, + account_repository: StaffAccountRepository, + security_service: AdminSecurityService, + audit_service: AuditService, + ): + self.account_repository = account_repository + self.security_service = security_service + self.audit_service = audit_service + + def list_collaborators(self) -> dict: + collaborators = self.account_repository.list_by_role(StaffRole.COLABORADOR) + active_count = sum(1 for account in collaborators if account.is_active) + return { + "accounts": [self._serialize_staff_account(account) for account in collaborators], + "total": len(collaborators), + "active_count": active_count, + "inactive_count": len(collaborators) - active_count, + } + + def create_collaborator( + self, + *, + email: str, + display_name: str, + password: str, + is_active: bool, + actor_staff_account_id: int, + ip_address: str | None, + user_agent: str | None, + ) -> dict: + normalized_email = self._normalize_email(email) + normalized_display_name = display_name.strip() + if len(normalized_display_name) < 3: + raise ValueError("display_name precisa ter pelo menos 3 caracteres.") + if self.account_repository.get_by_email(normalized_email) is not None: + raise ValueError("Ja existe uma conta administrativa com este email.") + + password_hash = self.security_service.hash_password(password) + collaborator = self.account_repository.create( + email=normalized_email, + display_name=normalized_display_name, + password_hash=password_hash, + role=StaffRole.COLABORADOR, + is_active=is_active, + ) + self.audit_service.record_staff_account_created( + actor_staff_account_id=actor_staff_account_id, + created_staff_account_id=collaborator.id, + email=collaborator.email, + role=collaborator.role.value, + ip_address=ip_address, + user_agent=user_agent, + ) + return self._serialize_staff_account(collaborator) + + def update_collaborator_status( + self, + *, + collaborator_id: int, + is_active: bool, + actor_staff_account_id: int, + ip_address: str | None, + user_agent: str | None, + ) -> dict: + collaborator = self.account_repository.get_by_id(collaborator_id) + if collaborator is None or collaborator.role != StaffRole.COLABORADOR: + raise LookupError("Colaborador administrativo nao encontrado.") + + collaborator.is_active = is_active + persisted = self.account_repository.save(collaborator) + self.audit_service.record_staff_account_status_updated( + actor_staff_account_id=actor_staff_account_id, + target_staff_account_id=persisted.id, + is_active=persisted.is_active, + ip_address=ip_address, + user_agent=user_agent, + ) + return self._serialize_staff_account(persisted) + + @staticmethod + def _normalize_email(email: str) -> str: + return email.strip().lower() + + @staticmethod + def _serialize_staff_account(account: StaffAccount) -> dict: + return { + "id": account.id, + "email": account.email, + "display_name": account.display_name, + "role": account.role, + "is_active": account.is_active, + "last_login_at": account.last_login_at, + "created_at": account.created_at, + "updated_at": account.updated_at, + } diff --git a/admin_app/services/tool_management_service.py b/admin_app/services/tool_management_service.py index 99e4e82..b8c0cb1 100644 --- a/admin_app/services/tool_management_service.py +++ b/admin_app/services/tool_management_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from dataclasses import dataclass from datetime import UTC, datetime @@ -16,6 +17,13 @@ class BootstrapToolCatalogEntry: parameter_count: int +@dataclass(frozen=True) +class ToolIntakeDomainOption: + value: str + label: str + description: str + + _BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( BootstrapToolCatalogEntry( tool_name="consultar_estoque", @@ -145,6 +153,29 @@ _BOOTSTRAP_TOOL_CATALOG: tuple[BootstrapToolCatalogEntry, ...] = ( ), ) +_INTAKE_DOMAIN_OPTIONS: tuple[ToolIntakeDomainOption, ...] = ( + ToolIntakeDomainOption( + value="vendas", + label="Vendas", + description="Ferramentas para estoque, negociacao, pedido e conversao comercial.", + ), + ToolIntakeDomainOption( + value="revisao", + label="Revisao", + description="Ferramentas para agendamento, remarcacao e operacao da oficina.", + ), + ToolIntakeDomainOption( + value="locacao", + label="Locacao", + description="Ferramentas para frota, contratos, devolucao e arrecadacao de aluguel.", + ), + ToolIntakeDomainOption( + value="orquestracao", + label="Orquestracao", + description="Ferramentas internas para fluxo conversacional, contexto e decisao do bot.", + ), +) + _LIFECYCLE_DESCRIPTIONS = { ToolLifecycleStatus.DRAFT: "Estado inicial de uma tool ainda em definicao.", ToolLifecycleStatus.GENERATED: "Implementacao gerada e pronta para analise tecnica.", @@ -164,6 +195,9 @@ _PARAMETER_TYPE_DESCRIPTIONS = { ToolParameterType.ARRAY: "Colecoes ordenadas de valores.", } +_TOOL_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{2,63}$") +_PARAMETER_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,63}$") + class ToolManagementService: def __init__(self, settings: AdminSettings): @@ -196,13 +230,13 @@ class ToolManagementService: "key": "draft_persistence", "label": "Persistencia de drafts", "value": "pendente", - "description": "A fase atual entrega as superficies e o contrato; entidades de draft ainda nao existem.", + "description": "A fase atual entrega uma tela real de cadastro com validacao; a persistencia entra na fase seguinte.", }, ], "workflow": self.build_lifecycle_payload(), "next_steps": [ "Criar entidades administrativas para ToolDraft, ToolValidationRun e ToolPublication.", - "Ligar o formulario de cadastro de novas tools a uma persistencia propria do admin.", + "Persistir o pre-cadastro validado da nova tela em armazenamento proprio do admin.", "Abrir filas de revisao, aprovacao e ativacao com auditoria ponta a ponta.", ], } @@ -242,11 +276,47 @@ class ToolManagementService: ], } + def build_draft_form_payload(self) -> dict: + return { + "mode": "validated_preview", + "domain_options": [ + { + "value": option.value, + "label": option.label, + "description": option.description, + } + for option in _INTAKE_DOMAIN_OPTIONS + ], + "parameter_types": [ + { + "code": parameter_type, + "label": parameter_type.value.upper(), + "description": _PARAMETER_TYPE_DESCRIPTIONS[parameter_type], + } + for parameter_type in ToolParameterType + ], + "naming_rules": [ + "tool_name deve usar snake_case minusculo, sem espacos, com 3 a 64 caracteres.", + "display_name deve explicar claramente a acao operacional que o bot vai executar.", + "Cada parametro precisa de nome, tipo, descricao e marcador de obrigatoriedade.", + ], + "submission_notes": [ + "O colaborador pode preencher e validar o pre-cadastro da tool no painel.", + "Toda tool nova segue para revisao e aprovacao de um diretor antes de qualquer publicacao.", + "Nesta fase a tela valida o cadastro e monta o preview, enquanto a persistencia definitiva entra na fase 5.", + ], + "approval_notes": [ + "Diretor revisa objetivo, parametros e aderencia ao contrato compartilhado.", + "A publicacao para o runtime de produto so pode acontecer apos aprovacao humana.", + "Campos livres e payloads complexos exigem criterio maior na etapa de revisao.", + ], + } + def build_drafts_payload(self) -> dict: return { "storage_status": "pending_persistence", "message": ( - "As rotas de gestao de tools ja existem, mas a persistencia de ToolDraft ainda sera criada nas proximas etapas." + "A nova tela de cadastro ja valida o pre-cadastro da tool no painel, mas a persistencia de ToolDraft ainda sera criada nas proximas etapas." ), "drafts": [], "supported_statuses": [ToolLifecycleStatus.DRAFT], @@ -274,6 +344,38 @@ class ToolManagementService: "publications": self.list_publication_catalog(), } + def preview_draft_submission(self, payload: dict, *, owner_name: str | None = None) -> dict: + normalized = self._normalize_draft_payload(payload) + warnings = self._build_intake_warnings(normalized) + required_parameter_count = sum(1 for parameter in normalized["parameters"] if parameter["required"]) + summary = ( + f"{normalized['display_name']} pronta para seguir como draft com {len(normalized['parameters'])} parametro(s) e revisao obrigatoria de diretor." + ) + return { + "storage_status": "validated_preview", + "message": "Pre-cadastro validado no painel. A persistencia definitiva entra na fase de governanca de tools.", + "draft_preview": { + "draft_id": f"preview::{normalized['tool_name']}", + "tool_name": normalized["tool_name"], + "display_name": normalized["display_name"], + "domain": normalized["domain"], + "status": ToolLifecycleStatus.DRAFT, + "summary": summary, + "business_goal": normalized["business_goal"], + "parameter_count": len(normalized["parameters"]), + "required_parameter_count": required_parameter_count, + "requires_director_approval": True, + "owner_name": owner_name, + "parameters": normalized["parameters"], + }, + "warnings": warnings, + "next_steps": [ + "Persistir o draft administrativo em armazenamento proprio do admin na fase 5.", + "Encaminhar a tool para revisao e aprovacao de um diretor.", + "Executar pipeline de geracao, validacao e publicacao antes da ativacao no produto.", + ], + } + def build_lifecycle_payload(self) -> list[dict]: return [ { @@ -303,3 +405,86 @@ class ToolManagementService: } for entry in _BOOTSTRAP_TOOL_CATALOG ] + + def _normalize_draft_payload(self, payload: dict) -> dict: + tool_name = str(payload.get("tool_name") or "").strip().lower() + if not _TOOL_NAME_PATTERN.fullmatch(tool_name): + raise ValueError("tool_name deve usar snake_case minusculo com 3 a 64 caracteres.") + + display_name = str(payload.get("display_name") or "").strip() + if len(display_name) < 4: + raise ValueError("display_name precisa ter pelo menos 4 caracteres.") + + domain = str(payload.get("domain") or "").strip().lower() + valid_domains = {option.value for option in _INTAKE_DOMAIN_OPTIONS} + if domain not in valid_domains: + raise ValueError("Selecione um dominio valido para a nova tool.") + + description = str(payload.get("description") or "").strip() + if len(description) < 16: + raise ValueError("A descricao precisa ter pelo menos 16 caracteres para contextualizar a tool.") + + business_goal = str(payload.get("business_goal") or "").strip() + if len(business_goal) < 12: + raise ValueError("Explique o objetivo operacional da tool com pelo menos 12 caracteres.") + + raw_parameters = payload.get("parameters") or [] + if not isinstance(raw_parameters, list): + raise ValueError("Os parametros enviados para a tool sao invalidos.") + + seen_parameter_names: set[str] = set() + parameters: list[dict] = [] + for raw_parameter in raw_parameters: + name = str((raw_parameter or {}).get("name") or "").strip().lower() + if not name: + continue + if not _PARAMETER_NAME_PATTERN.fullmatch(name): + raise ValueError("Cada parametro deve usar snake_case minusculo com pelo menos 2 caracteres.") + if name in seen_parameter_names: + raise ValueError("Nao e permitido repetir nomes de parametro na mesma tool.") + seen_parameter_names.add(name) + + raw_parameter_type = (raw_parameter or {}).get("parameter_type") or "" + parameter_type = ( + raw_parameter_type + if isinstance(raw_parameter_type, ToolParameterType) + else ToolParameterType(str(raw_parameter_type).strip().lower()) + ) + parameter_description = str((raw_parameter or {}).get("description") or "").strip() + if len(parameter_description) < 8: + raise ValueError("Cada parametro precisa de uma descricao com pelo menos 8 caracteres.") + + parameters.append( + { + "name": name, + "parameter_type": parameter_type, + "description": parameter_description, + "required": bool((raw_parameter or {}).get("required", True)), + } + ) + + if len(parameters) > 10: + raise ValueError("A fase inicial do painel aceita no maximo 10 parametros por tool.") + + return { + "tool_name": tool_name, + "display_name": display_name, + "domain": domain, + "description": description, + "business_goal": business_goal, + "parameters": parameters, + } + + def _build_intake_warnings(self, payload: dict) -> list[str]: + warnings: list[str] = [] + parameters = payload["parameters"] + if not parameters: + warnings.append("A tool foi cadastrada sem parametros. Confirme se a acao realmente nao exige entrada contextual.") + if len(parameters) >= 6: + warnings.append("A quantidade de parametros ja pede uma revisao mais cuidadosa antes da aprovacao de diretor.") + if any(parameter["parameter_type"] in {ToolParameterType.OBJECT, ToolParameterType.ARRAY} for parameter in parameters): + warnings.append("Parametros compostos exigem atencao extra na revisao porque podem esconder payloads mais sensiveis.") + if payload["domain"] == "orquestracao": + warnings.append("Tools de orquestracao precisam confirmar claramente como afetam o fluxo do bot antes da ativacao.") + return warnings + diff --git a/admin_app/view/rendering.py b/admin_app/view/rendering.py index 2b82fa6..35f0a24 100644 --- a/admin_app/view/rendering.py +++ b/admin_app/view/rendering.py @@ -1,6 +1,7 @@ -from html import escape +from html import escape from admin_app.view.view_models import ( + AdminCollaboratorManagementPageView, AdminLoginPageView, AdminPanelHomeView, AdminPanelMetric, @@ -9,10 +10,11 @@ from admin_app.view.view_models import ( AdminPanelQuickAction, AdminPanelRoadmapItem, AdminPanelSurfaceLink, + AdminToolIntakePageView, + AdminToolIntakeParameterTypeOption, AdminToolReviewPageView, AdminToolReviewWorkflowStep, ) - BOOTSTRAP_CSS_HREF = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" BOOTSTRAP_JS_HREF = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" _BADGE_CLASS_MAP = { @@ -723,3 +725,515 @@ def _render_tool_review_workflow(items: tuple[AdminToolReviewWorkflowStep, ...]) + + + +def render_tool_intake_page( + view: AdminToolIntakePageView, + *, + css_href: str, + js_href: str, +) -> str: + naming_rules_markup = _render_text_list(view.naming_rules) + submission_notes_markup = _render_text_list(view.submission_notes) + approval_notes_markup = _render_text_list(view.approval_notes) + domain_cards_markup = "\n".join( + f'''
{escape(item.label)}
{escape(item.description)}
''' + for item in view.domain_options + ) + parameter_type_badges_markup = "\n".join( + f'''{escape(item.label)}''' + for item in view.parameter_type_options + ) + domain_select_options = "\n".join( + f'''''' + for item in view.domain_options + ) + parameter_select_options = _render_tool_parameter_options(view.parameter_type_options) + + return f''' + + + + + {escape(view.title)} + + + + + +
+
+ + +
+
+
+
+
+
+ Nova tool + Preview validado +
+

Cadastrar uma nova tool com contexto operacional

+

+ A tela abaixo transforma o cadastro em um pre-draft validado, pronto para seguir ao fluxo de revisao humana antes de qualquer publicacao no produto. +

+
+
+ {parameter_type_badges_markup} +
+
+
+
+ +
+ +
+
+
+
+
+
+

Formulario principal

+

Preencher os dados da nova tool

+

O objetivo aqui e validar estrutura, objetivo operacional e parametros antes da persistencia definitiva.

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+

Parametros

+

Estrutura de entrada da tool

+

Adicione somente os parametros realmente necessarios para a decisao do bot.

+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+
+
+
+ +
+ + Ir para revisao +
+
+ + +
+
+
+ +
+
+
+
+

Dominios sugeridos

+
{domain_cards_markup}
+
+ +
+

Orientacoes da fase atual

+
    + {submission_notes_markup} +
+
+ +
+
+
+

Preview do draft

+

Resultado da validacao

+
+ Aguardando +
+
+
+

Nenhum pre-cadastro validado ainda

+

Assim que o formulario for validado, o resumo do draft aparece aqui com avisos e proximos passos.

+
+
+
+
+
+
+
+
+
+
+ + + + + +''' + + +def _render_tool_parameter_options(items: tuple[AdminToolIntakeParameterTypeOption, ...]) -> str: + return "\n".join( + f'' + for item in items + ) + +def render_collaborator_management_page( + view: AdminCollaboratorManagementPageView, + *, + css_href: str, + js_href: str, +) -> str: + onboarding_notes_markup = _render_text_list(view.onboarding_notes) + governance_notes_markup = _render_text_list(view.governance_notes) + + return f''' + + + + + {escape(view.title)} + + + + + +
+
+ + +
+
+
+
+
+
+ Gestao de equipe + Sessao protegida +
+

Cadastro e status da equipe administrativa

+

+ Esta area centraliza o onboarding de colaboradores e deixa o diretor com uma leitura simples de quem esta ativo no painel. +

+
+
+

Politica de senha

+
{escape(view.password_policy_label)}
+
+
+
+
+ +
+ +
+
+
+
+

Total de colaboradores

+
0
+

Contas administrativas de colaborador cadastradas no painel.

+
+
+
+
+
+
+

Ativos

+
0
+

Colaboradores que podem entrar normalmente no admin.

+
+
+
+
+
+
+

Inativos

+
0
+

Acessos pausados sem remover rastreabilidade da conta.

+
+
+
+
+ +
+
+
+
+
+
+

Novo acesso

+

Cadastrar colaborador

+

Crie a conta inicial da equipe com nome, email e senha provisoria ja validada pela politica do admin.

+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ Politica atual: {escape(view.password_policy_label)} +
+
+
+
+ + +
+
+
+ +
+ + +
+
+
+
+
+ +
+
+
+
+
+

Equipe cadastrada

+

Leitura atual da equipe interna

+

A lista abaixo vem da superficie web do painel e permite ligar ou desligar acessos rapidamente.

+
+ +
+ +
+
+

Nenhum colaborador carregado ainda

+

Clique em atualizar lista para sincronizar o estado atual da equipe.

+
+
+
+
+
+
+
+
+
+ + + + + +''' + + +def _render_collaborator_cards(items: list[dict]) -> str: + return "\n".join( + f'''
{escape(str(item.get("role") or "colaborador"))}

{escape(str(item.get("display_name") or "Colaborador"))}

{escape(str(item.get("email") or ""))}
{'Ativo' if item.get("is_active") else 'Inativo'}
Ultimo login: {escape(str(item.get("last_login_at") or "Ainda nao acessou"))}
ID: {escape(str(item.get("id") or "-"))}
''' + for item in items + ) + diff --git a/admin_app/view/router.py b/admin_app/view/router.py index fcb84fb..78ab08a 100644 --- a/admin_app/view/router.py +++ b/admin_app/view/router.py @@ -1,11 +1,19 @@ -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import HTMLResponse, RedirectResponse, Response from admin_app.api.dependencies import get_optional_panel_staff_context from admin_app.core import AdminSettings, AuthenticatedStaffContext, get_admin_settings +from admin_app.services import ToolManagementService from admin_app.view.assets import PANEL_STATIC_MOUNT_NAME -from admin_app.view.rendering import render_login_page, render_panel_home, render_tool_review_page +from admin_app.view.rendering import ( + render_collaborator_management_page, + render_login_page, + render_panel_home, + render_tool_intake_page, + render_tool_review_page, +) from admin_app.view.view_models import ( + AdminCollaboratorManagementPageView, AdminLoginPageView, AdminPanelHomeView, AdminPanelMetric, @@ -14,10 +22,13 @@ from admin_app.view.view_models import ( AdminPanelQuickAction, AdminPanelRoadmapItem, AdminPanelSurfaceLink, + AdminToolIntakeDomainOption, + AdminToolIntakePageView, + AdminToolIntakeParameterTypeOption, AdminToolReviewPageView, AdminToolReviewWorkflowStep, ) -from shared.contracts import AdminPermission, StaffRole +from shared.contracts import AdminPermission, StaffRole, role_has_permission panel_router = APIRouter(tags=["panel"]) @@ -40,7 +51,7 @@ def panel_home( return _redirect_to_route(request, "admin_login_view") settings = _resolve_settings(request) - view = _build_home_view(request, settings) + view = _build_home_view(request, settings, current_context) css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css")) js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js")) return HTMLResponse(render_panel_home(view, css_href=css_href, js_href=js_href)) @@ -61,6 +72,21 @@ def login_page( return HTMLResponse(render_login_page(view, css_href=css_href, js_href=js_href)) +@panel_router.get("/panel/tools/new", response_class=HTMLResponse, name="admin_tool_intake_view") +def tool_intake_page( + request: Request, + current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context), +) -> Response: + if current_context is None: + return _redirect_to_route(request, "admin_login_view") + + settings = _resolve_settings(request) + view = _build_tool_intake_view(request, settings) + css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css")) + js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js")) + return HTMLResponse(render_tool_intake_page(view, css_href=css_href, js_href=js_href)) + + @panel_router.get("/panel/tools/review", response_class=HTMLResponse, name="admin_tool_review_view") def tool_review_page( request: Request, @@ -76,169 +102,256 @@ def tool_review_page( return HTMLResponse(render_tool_review_page(view, css_href=css_href, js_href=js_href)) -def _build_home_view(request: Request, settings: AdminSettings) -> AdminPanelHomeView: +@panel_router.get("/panel/colaboradores/gestao", response_class=HTMLResponse, name="admin_collaborator_management_view") +def collaborator_management_page( + request: Request, + current_context: AuthenticatedStaffContext | None = Depends(get_optional_panel_staff_context), +) -> Response: + if current_context is None: + return _redirect_to_route(request, "admin_login_view") + if not role_has_permission(current_context.principal.role, AdminPermission.MANAGE_STAFF_ACCOUNTS): + return _redirect_to_route(request, "panel_home") + + settings = _resolve_settings(request) + view = _build_collaborator_management_view(request, settings) + css_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="styles/panel.css")) + js_href = str(request.url_for(PANEL_STATIC_MOUNT_NAME, path="scripts/panel.js")) + return HTMLResponse(render_collaborator_management_page(view, css_href=css_href, js_href=js_href)) + + +def _build_home_view( + request: Request, + settings: AdminSettings, + current_context: AuthenticatedStaffContext, +) -> AdminPanelHomeView: panel_href = str(request.url_for("panel_home")) + tool_intake_view_href = str(request.url_for("admin_tool_intake_view")) tool_review_view_href = str(request.url_for("admin_tool_review_view")) + collaborator_management_view_href = str(request.url_for("admin_collaborator_management_view")) system_configuration_href = _build_prefixed_path(settings.admin_api_prefix, "/system/configuration") audit_href = _build_prefixed_path(settings.admin_api_prefix, "/audit/events") + can_manage_collaborators = role_has_permission( + current_context.principal.role, + AdminPermission.MANAGE_STAFF_ACCOUNTS, + ) - return AdminPanelHomeView( - service="orquestrador-admin", - app_name=settings.admin_app_name, - panel_title="Painel Administrativo", - panel_subtitle=( - "Area interna protegida para operar o admin com mais clareza, foco e navegacao orientada por fluxo." + navigation = [ + AdminPanelNavigationItem( + label="Dashboard", + href=panel_href, + description="Entrada principal do ambiente interno.", + badge="Ativo", + is_active=True, ), - environment=settings.admin_environment, - version=settings.admin_version, - api_prefix=settings.admin_api_prefix or "/", - release_label="Bootstrap UI v1", - navigation=( - AdminPanelNavigationItem( - label="Dashboard", - href=panel_href, - description="Entrada principal do ambiente interno.", - badge="Ativo", - is_active=True, + AdminPanelNavigationItem( + label="Cadastro de tools", + href=tool_intake_view_href, + description="Pre-cadastro validado para novas tools antes da revisao.", + badge="Novo", + ), + AdminPanelNavigationItem( + label="Revisao de tools", + href=tool_review_view_href, + description="Fluxo humano de revisao, aprovacao e ativacao.", + badge="Operacao", + ), + AdminPanelNavigationItem( + label="Areas do sistema", + href="#modules", + description="Mapa claro dos modulos internos disponiveis.", + badge="Painel", + ), + AdminPanelNavigationItem( + label="Fluxo recomendado", + href="#workflow", + description="Sequencia sugerida para operar o admin.", + badge="Guia", + ), + ] + quick_actions = [ + AdminPanelQuickAction( + label="Cadastrar tool", + href=tool_intake_view_href, + button_class="btn-dark", + ), + AdminPanelQuickAction( + label="Revisar tools", + href=tool_review_view_href, + button_class="btn-outline-dark", + ), + AdminPanelQuickAction( + label="Ver areas", + href="#modules", + button_class="btn-outline-secondary", + ), + ] + modules = [ + AdminPanelModuleCard( + eyebrow="Fluxo de entrada", + title="Cadastro de tools", + description="Tela real para o colaborador preencher metadados, definir parametros e validar o pre-cadastro antes da revisao humana.", + status_label="Tela ativa", + status_variant="success", + highlights=( + "Formulario protegido por sessao web", + "Preview validado antes da persistencia", + "Direcao clara para revisao de diretor", ), - AdminPanelNavigationItem( - label="Revisao de tools", - href=tool_review_view_href, - description="Fluxo humano de revisao, aprovacao e ativacao.", - badge="Operacao", + cta_label="Abrir cadastro", + href=tool_intake_view_href, + is_available=True, + ), + AdminPanelModuleCard( + eyebrow="Fluxo principal", + title="Revisao de tools", + description="Area operacional do painel para leitura da fila, aprovacao humana e ativacao controlada.", + status_label="Tela ativa", + status_variant="success", + highlights=( + "Fila protegida por sessao web", + "Catalogo ativo para comparacao", + "Leitura clara do workflow de aprovacao", ), - AdminPanelNavigationItem( - label="Areas do sistema", - href="#modules", - description="Mapa claro dos modulos internos disponiveis.", - badge="Painel", + cta_label="Abrir revisao", + href=tool_review_view_href, + is_available=True, + ), + AdminPanelModuleCard( + eyebrow="Acompanhamento", + title="Configuracao do sistema", + description="Snapshot do runtime administrativo, politicas de seguranca e dados de sessao do painel.", + status_label="API pronta", + status_variant="secondary", + highlights=( + "Runtime e banco monitorados", + "Politicas de credencial centralizadas", + "Base pronta para futura tela dedicada", ), - AdminPanelNavigationItem( - label="Fluxo recomendado", - href="#workflow", - description="Sequencia sugerida para operar o admin.", - badge="Guia", + ), + AdminPanelModuleCard( + eyebrow="Governanca", + title="Auditoria operacional", + description="Eventos de login, logout, aprovacao e publicacao continuam registrados para rastreabilidade.", + status_label="Auditavel", + status_variant="secondary", + highlights=( + "Historico de operacao interna", + "Base para filtros e timeline", + "Suporte a conformidade do fluxo administrativo", ), ), - quick_actions=( - AdminPanelQuickAction( - label="Revisar tools", - href=tool_review_view_href, - button_class="btn-dark", + ] + surface_links = [ + AdminPanelSurfaceLink( + method="Acesso", + label="Dashboard administrativa", + href=panel_href, + description="Entrada principal do time interno depois do login.", + ), + AdminPanelSurfaceLink( + method="Cadastro", + label="Nova tool", + href=tool_intake_view_href, + description="Formulario real para validar o pre-cadastro de uma nova tool.", + ), + AdminPanelSurfaceLink( + method="Operacao", + label="Revisao de tools", + href=tool_review_view_href, + description="Area com fila, contrato e catalogo ativo para tomada de decisao.", + ), + AdminPanelSurfaceLink( + method="Runtime", + label="Configuracao do sistema", + href=system_configuration_href, + description="Snapshot tecnico do ambiente, mantido como superficie protegida enquanto a tela visual nao chega.", + ), + AdminPanelSurfaceLink( + method="Auditoria", + label="Eventos administrativos", + href=audit_href, + description="Consulta de eventos internos para rastrear operacoes sensiveis.", + ), + ] + roadmap = [ + AdminPanelRoadmapItem( + step="01", + title="Entrar pelo login administrativo", + description="A sessao web libera o ambiente interno e evita navegacao confusa antes da autenticacao.", + status_label="Obrigatorio", + ), + AdminPanelRoadmapItem( + step="02", + title="Passar pela dashboard", + description="A home protegida organiza os modulos e mostra por onde comecar a operacao.", + status_label="Entrada", + ), + AdminPanelRoadmapItem( + step="03", + title="Cadastrar ou validar o pre-draft", + description="Use a nova tela para descrever a tool, seus parametros e o objetivo operacional antes da revisao.", + status_label="Cadastro", + ), + AdminPanelRoadmapItem( + step="04", + title="Abrir revisao de tools", + description="Encaminhe a ferramenta para analise humana, aprovacao e ativacao controlada.", + status_label="Principal", + ), + AdminPanelRoadmapItem( + step="05", + title="Consultar runtime e auditoria", + description="Quando necessario, acompanhe configuracao e eventos do admin para suportar a decisao operacional.", + status_label="Suporte", + ), + ] + + if can_manage_collaborators: + navigation.insert( + 3, + AdminPanelNavigationItem( + label="Colaboradores", + href=collaborator_management_view_href, + description="Cadastro e governanca de acessos internos, exclusivo para diretor.", + badge="Diretor", ), + ) + quick_actions.insert( + 2, AdminPanelQuickAction( - label="Ver areas", - href="#modules", + label="Gerir equipe", + href=collaborator_management_view_href, button_class="btn-outline-dark", ), - AdminPanelQuickAction( - label="Ver fluxo", - href="#workflow", - button_class="btn-outline-secondary", - ), - ), - metrics=( - AdminPanelMetric( - label="Runtimes independentes", - value="2", - description="Produto e admin seguem isolados para deploy e operacao.", - ), - AdminPanelMetric( - label="Perfis internos", - value=str(len(StaffRole)), - description="Hierarquia base com viewer, staff e admin.", - ), - AdminPanelMetric( - label="Permissoes administrativas", - value=str(len(AdminPermission)), - description="Camada pronta para crescer por modulo sem misturar contexto.", - ), - AdminPanelMetric( - label="Refresh token", - value=f"{settings.admin_auth_refresh_token_ttl_days} dias", - description="Sessao web persistida com renovacao controlada.", - ), - ), - modules=( + ) + modules.insert( + 2, AdminPanelModuleCard( - eyebrow="Fluxo principal", - title="Revisao de tools", - description="A principal area operacional do painel para leitura da fila, aprovacao humana e ativacao controlada.", + eyebrow="Governanca de acesso", + title="Gestao de colaboradores", + description="Tela dedicada para o diretor criar contas internas, acompanhar o status da equipe e manter a entrada administrativa sob controle.", status_label="Tela ativa", - status_variant="success", + status_variant="dark", highlights=( - "Fila protegida por sessao web", - "Catalogo ativo para comparacao", - "Leitura clara do workflow de aprovacao", + "Criacao de colaborador com senha inicial", + "Ativacao e desativacao sem tocar no banco manualmente", + "Fluxo exclusivo para diretor", ), - cta_label="Abrir revisao", - href=tool_review_view_href, + cta_label="Abrir equipe", + href=collaborator_management_view_href, is_available=True, ), - AdminPanelModuleCard( - eyebrow="Acompanhamento", - title="Configuracao do sistema", - description="Snapshot do runtime administrativo, politicas de seguranca e dados de sessao do painel.", - status_label="API pronta", - status_variant="secondary", - highlights=( - "Runtime e banco monitorados", - "Politicas de credencial centralizadas", - "Base pronta para futura tela dedicada", - ), - ), - AdminPanelModuleCard( - eyebrow="Governanca", - title="Auditoria operacional", - description="Eventos de login, logout, aprovacao e publicacao continuam registrados para rastreabilidade.", - status_label="Auditavel", - status_variant="secondary", - highlights=( - "Historico de operacao interna", - "Base para filtros e timeline", - "Suporte a conformidade do fluxo administrativo", - ), - ), - AdminPanelModuleCard( - eyebrow="Seguranca", - title="Sessao administrativa", - description="Acesso ao painel protegido por StaffAccount, token assinado e refresh token rotacionado.", - status_label="Protegido", - status_variant="success", - highlights=( - "StaffAccount isolado do usuario final", - "Cookies httpOnly no navegador", - "Rotacao controlada da sessao web", - ), - ), - ), - surface_links=( + ) + surface_links.insert( + 3, AdminPanelSurfaceLink( - method="Acesso", - label="Dashboard administrativa", - href=panel_href, - description="Entrada principal do time interno depois do login.", + method="Equipe", + label="Gestao de colaboradores", + href=collaborator_management_view_href, + description="Controle de acesso interno para cadastrar e administrar a equipe administrativa.", ), - AdminPanelSurfaceLink( - method="Operacao", - label="Revisao de tools", - href=tool_review_view_href, - description="Area com fila, contrato e catalogo ativo para tomada de decisao.", - ), - AdminPanelSurfaceLink( - method="Runtime", - label="Configuracao do sistema", - href=system_configuration_href, - description="Snapshot tecnico do ambiente, mantido como superficie protegida enquanto a tela visual nao chega.", - ), - AdminPanelSurfaceLink( - method="Auditoria", - label="Eventos administrativos", - href=audit_href, - description="Consulta de eventos internos para rastrear operacoes sensiveis.", - ), - ), - roadmap=( + ) + roadmap = [ AdminPanelRoadmapItem( step="01", title="Entrar pelo login administrativo", @@ -253,25 +366,74 @@ def _build_home_view(request: Request, settings: AdminSettings) -> AdminPanelHom ), AdminPanelRoadmapItem( step="03", + title="Cadastrar ou validar o pre-draft", + description="Use a nova tela para descrever a tool, seus parametros e o objetivo operacional antes da revisao.", + status_label="Cadastro", + ), + AdminPanelRoadmapItem( + step="04", + title="Organizar a equipe interna", + description="Diretores podem cadastrar novos colaboradores e controlar rapidamente o status de acesso administrativo.", + status_label="Diretor", + ), + AdminPanelRoadmapItem( + step="05", title="Abrir revisao de tools", - description="Use o hub de revisao para analisar fila, contrato e ativacao das tools.", + description="Encaminhe a ferramenta para analise humana, aprovacao e ativacao controlada.", status_label="Principal", ), AdminPanelRoadmapItem( - step="04", + step="06", title="Consultar runtime e auditoria", description="Quando necessario, acompanhe configuracao e eventos do admin para suportar a decisao operacional.", status_label="Suporte", ), + ] + + return AdminPanelHomeView( + service="orquestrador-admin", + app_name=settings.admin_app_name, + panel_title="Painel Administrativo", + panel_subtitle=( + "Area interna protegida para operar o admin com mais clareza, foco e navegacao orientada por fluxo." + ), + environment=settings.admin_environment, + version=settings.admin_version, + api_prefix=settings.admin_api_prefix or "/", + release_label="Bootstrap UI v1", + navigation=tuple(navigation), + quick_actions=tuple(quick_actions), + metrics=( + AdminPanelMetric( + label="Runtimes independentes", + value="2", + description="Produto e admin seguem isolados para deploy e operacao.", + ), + AdminPanelMetric( + label="Perfis internos", + value=str(len(StaffRole)), + description="Hierarquia base com colaborador e diretor.", + ), + AdminPanelMetric( + label="Permissoes administrativas", + value=str(len(AdminPermission)), + description="Camada pronta para crescer por modulo sem misturar contexto.", + ), + AdminPanelMetric( + label="Refresh token", + value=f"{settings.admin_auth_refresh_token_ttl_days} dias", + description="Sessao web persistida com renovacao controlada.", + ), ), + modules=tuple(modules), + surface_links=tuple(surface_links), + roadmap=tuple(roadmap), ) def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPageView: dashboard_href = str(request.url_for("panel_home")) auth_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/auth/login") - session_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/auth/session") - logout_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/auth/logout") password_requirements = [] if settings.admin_auth_password_require_uppercase: password_requirements.append("maiuscula") @@ -297,8 +459,6 @@ def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPa version=settings.admin_version, dashboard_href=dashboard_href, auth_endpoint=auth_endpoint, - session_endpoint=session_endpoint, - logout_endpoint=logout_endpoint, email_placeholder="voce@empresa.com", password_placeholder="Sua senha administrativa", access_token_ttl_label=f"{settings.admin_auth_access_token_ttl_minutes} minutos", @@ -317,9 +477,45 @@ def _build_login_view(request: Request, settings: AdminSettings) -> AdminLoginPa ) +def _build_tool_intake_view(request: Request, settings: AdminSettings) -> AdminToolIntakePageView: + service = ToolManagementService(settings) + form_payload = service.build_draft_form_payload() + + return AdminToolIntakePageView( + app_name=settings.admin_app_name, + title="Cadastro de nova tool", + subtitle=( + "Formulario guiado para o colaborador estruturar uma nova tool, validar o pre-draft e encaminhar a proposta para revisao de diretor." + ), + environment=settings.admin_environment, + version=settings.admin_version, + dashboard_href=str(request.url_for("panel_home")), + review_href=str(request.url_for("admin_tool_review_view")), + intake_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/tools/drafts/intake"), + domain_options=tuple( + AdminToolIntakeDomainOption( + value=item["value"], + label=item["label"], + description=item["description"], + ) + for item in form_payload["domain_options"] + ), + parameter_type_options=tuple( + AdminToolIntakeParameterTypeOption( + value=item["code"].value, + label=item["label"], + description=item["description"], + ) + for item in form_payload["parameter_types"] + ), + naming_rules=tuple(form_payload["naming_rules"]), + submission_notes=tuple(form_payload["submission_notes"]), + approval_notes=tuple(form_payload["approval_notes"]), + ) + + def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminToolReviewPageView: dashboard_href = str(request.url_for("panel_home")) - login_href = str(request.url_for("admin_login_view")) overview_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/overview") contracts_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/contracts") review_queue_endpoint = _build_prefixed_path(settings.admin_api_prefix, "/panel/tools/review-queue") @@ -334,7 +530,6 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT environment=settings.admin_environment, version=settings.admin_version, dashboard_href=dashboard_href, - login_href=login_href, overview_endpoint=overview_endpoint, contracts_endpoint=contracts_endpoint, review_queue_endpoint=review_queue_endpoint, @@ -380,6 +575,49 @@ def _build_tool_review_view(request: Request, settings: AdminSettings) -> AdminT ) +def _build_collaborator_management_view( + request: Request, + settings: AdminSettings, +) -> AdminCollaboratorManagementPageView: + password_requirements = [] + if settings.admin_auth_password_require_uppercase: + password_requirements.append("maiuscula") + if settings.admin_auth_password_require_lowercase: + password_requirements.append("minuscula") + if settings.admin_auth_password_require_digit: + password_requirements.append("digito") + if settings.admin_auth_password_require_symbol: + password_requirements.append("simbolo") + + password_policy_label = ( + f"Minimo de {settings.admin_auth_password_min_length} caracteres" + + (f" com {', '.join(password_requirements)}." if password_requirements else ".") + ) + + return AdminCollaboratorManagementPageView( + app_name=settings.admin_app_name, + title="Gestao de colaboradores", + subtitle=( + "Tela exclusiva de diretor para organizar a equipe interna, criar novos acessos administrativos e controlar rapidamente quem segue ativo no painel." + ), + environment=settings.admin_environment, + version=settings.admin_version, + dashboard_href=str(request.url_for("panel_home")), + collection_endpoint=_build_prefixed_path(settings.admin_api_prefix, "/panel/colaboradores"), + password_policy_label=password_policy_label, + onboarding_notes=( + "Novos acessos nascem sempre com papel de colaborador.", + "A senha inicial ja respeita a mesma politica do login administrativo.", + "Ativar ou desativar colaborador nao exige acesso direto ao banco.", + ), + governance_notes=( + "Somente diretor acessa esta tela e as rotas de gestao de colaboradores.", + "Cada criacao e alteracao de status gera trilha de auditoria administrativa.", + "A conta de diretor continua fora deste fluxo para evitar mudancas acidentais na governanca principal.", + ), + ) + + def _redirect_to_route(request: Request, route_name: str) -> RedirectResponse: return RedirectResponse(url=str(request.url_for(route_name)), status_code=302) diff --git a/admin_app/view/static/scripts/panel.js b/admin_app/view/static/scripts/panel.js index 6fceafa..21a7dd6 100644 --- a/admin_app/view/static/scripts/panel.js +++ b/admin_app/view/static/scripts/panel.js @@ -1,7 +1,9 @@ -document.documentElement.dataset.panelReady = "true"; +document.documentElement.dataset.panelReady = "true"; const loginForm = document.querySelector('[data-admin-login-form="true"]'); const reviewBoard = document.querySelector('[data-admin-tool-review-board="true"]'); +const toolIntakePage = document.querySelector('[data-admin-tool-intake="true"]'); +const collaboratorBoard = document.querySelector('[data-admin-collaborator-board="true"]'); if (loginForm) { mountLoginForm(loginForm); @@ -11,6 +13,14 @@ if (reviewBoard) { mountToolReviewBoard(reviewBoard); } +if (toolIntakePage) { + mountToolIntakePage(toolIntakePage); +} + +if (collaboratorBoard) { + mountCollaboratorBoard(collaboratorBoard); +} + function mountLoginForm(form) { const feedback = document.getElementById("admin-login-feedback"); const submitButton = form.querySelector('button[type="submit"]'); @@ -205,6 +215,345 @@ function mountToolReviewBoard(board) { } } +function mountToolIntakePage(page) { + const form = page.querySelector('[data-admin-tool-intake-form="true"]'); + const addButton = page.querySelector("[data-add-parameter-row]"); + const parameterList = page.querySelector("[data-parameter-list]"); + const template = page.querySelector("#admin-tool-parameter-row-template"); + const feedback = document.getElementById("admin-tool-intake-feedback"); + const preview = page.querySelector("[data-tool-intake-preview]"); + const storageStatus = page.querySelector("[data-tool-intake-storage-status]"); + const submitLabel = page.querySelector("[data-intake-submit-label]"); + const submitSpinner = page.querySelector("[data-intake-submit-spinner]"); + const submitButton = form?.querySelector('button[type="submit"]'); + + if (!form || !parameterList || !template || !feedback || !preview || !storageStatus || !submitLabel || !submitSpinner || !submitButton) { + return; + } + + if (addButton) { + addButton.addEventListener("click", () => { + appendParameterRow(); + }); + } + + parameterList.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof HTMLElement) || !target.matches("[data-remove-parameter-row]")) { + return; + } + + const rows = parameterList.querySelectorAll("[data-parameter-row]"); + const row = target.closest("[data-parameter-row]"); + if (!row) { + return; + } + + if (rows.length === 1) { + clearParameterRow(row); + return; + } + + row.remove(); + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + toggleSubmitting(true); + clearFeedback(); + + const payload = buildPayload(); + + try { + const response = await fetch(page.dataset.intakeEndpoint, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + const body = await readJson(response); + if (!response.ok) { + throw new Error(body?.detail || "Nao foi possivel validar o pre-cadastro da tool."); + } + + renderDraftPreview(body); + showFeedback("success", body?.message || "Pre-cadastro validado com sucesso."); + } catch (error) { + storageStatus.textContent = "Falha"; + showFeedback("danger", error instanceof Error ? error.message : "Erro inesperado ao validar a nova tool."); + } finally { + toggleSubmitting(false); + } + }); + + function appendParameterRow() { + const fragment = template.content.cloneNode(true); + parameterList.appendChild(fragment); + } + + function buildPayload() { + const formData = new FormData(form); + const parameters = Array.from(parameterList.querySelectorAll("[data-parameter-row]")) + .map((row) => { + const name = row.querySelector('[name="parameter_name"]')?.value?.trim() || ""; + const parameterType = row.querySelector('[name="parameter_type"]')?.value || "string"; + const description = row.querySelector('[name="parameter_description"]')?.value?.trim() || ""; + const required = Boolean(row.querySelector('[name="parameter_required"]')?.checked); + return { name, parameter_type: parameterType, description, required }; + }) + .filter((item) => item.name || item.description); + + return { + domain: String(formData.get("domain") || "").trim(), + tool_name: String(formData.get("tool_name") || "").trim(), + display_name: String(formData.get("display_name") || "").trim(), + description: String(formData.get("description") || "").trim(), + business_goal: String(formData.get("business_goal") || "").trim(), + parameters, + }; + } + + function clearParameterRow(row) { + row.querySelectorAll("input").forEach((input) => { + if (input.type === "checkbox") { + input.checked = true; + return; + } + input.value = ""; + }); + const select = row.querySelector('select[name="parameter_type"]'); + if (select) { + select.value = "string"; + } + } + + function toggleSubmitting(isLoading) { + submitButton.disabled = isLoading; + submitSpinner.classList.toggle("d-none", !isLoading); + submitLabel.textContent = isLoading ? "Validando..." : "Validar pre-cadastro"; + } + + function clearFeedback() { + feedback.className = "alert d-none rounded-4 mb-4"; + feedback.textContent = ""; + } + + function showFeedback(variant, message) { + feedback.className = `alert alert-${variant} rounded-4 mb-4`; + feedback.textContent = message; + } + + function renderDraftPreview(payload) { + const draft = payload?.draft_preview; + const warnings = Array.isArray(payload?.warnings) ? payload.warnings : []; + const nextSteps = Array.isArray(payload?.next_steps) ? payload.next_steps : []; + const parameters = Array.isArray(draft?.parameters) ? draft.parameters : []; + + storageStatus.textContent = payload?.storage_status || "Validado"; + preview.innerHTML = ` +
+
+
+
${escapeHtml(draft?.domain || "tool")}
+

${escapeHtml(draft?.display_name || "Nova tool")}

+
${escapeHtml(draft?.tool_name || "")}
+
+ ${escapeHtml(draft?.status || "draft")} +
+

${escapeHtml(draft?.summary || "")}

+
+
Objetivo: ${escapeHtml(draft?.business_goal || "")}
+
Parametros: ${escapeHtml(String(draft?.parameter_count || 0))}
+
Obrigatorios: ${escapeHtml(String(draft?.required_parameter_count || 0))}
+
Aprovacao: ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}
+
+
+ ${parameters.length > 0 + ? parameters.map((item) => `
${escapeHtml(item.name)}
${escapeHtml(item.description)}
${escapeHtml(item.parameter_type)}
`).join("") + : `
Sem parametros
A tool foi cadastrada sem parametros de entrada.
`} +
+
+
+
Avisos
+ ${warnings.length > 0 + ? `
    ${warnings.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` + : `
Nenhum aviso extra para este pre-cadastro.
`} +
+
+
Proximos passos
+ ${nextSteps.length > 0 + ? `
    ${nextSteps.map((item) => `
  • ${escapeHtml(item)}
  • `).join("")}
` + : `
Sem orientacoes adicionais.
`} +
+
+
+ `; + } +} + +function mountCollaboratorBoard(board) { + const form = board.querySelector('[data-admin-collaborator-form="true"]'); + const feedback = document.getElementById("admin-collaborator-feedback"); + const list = board.querySelector("[data-collaborator-list]"); + const refreshButton = board.querySelector("[data-admin-collaborator-refresh]"); + const refreshLabel = board.querySelector("[data-collaborator-refresh-label]"); + const refreshSpinner = board.querySelector("[data-collaborator-refresh-spinner]"); + const submitButton = form?.querySelector('button[type="submit"]'); + const submitLabel = form?.querySelector("[data-collaborator-submit-label]"); + const submitSpinner = form?.querySelector("[data-collaborator-submit-spinner]"); + + if (!form || !feedback || !list || !refreshButton || !refreshLabel || !refreshSpinner || !submitButton || !submitLabel || !submitSpinner) { + return; + } + + refreshButton.addEventListener("click", () => { + void loadCollaborators(); + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + toggleSubmitting(true); + clearFeedback(); + + const formData = new FormData(form); + const payload = { + display_name: String(formData.get("display_name") || "").trim(), + email: String(formData.get("email") || "").trim(), + password: String(formData.get("password") || ""), + is_active: Boolean(formData.get("is_active")), + }; + + try { + const response = await fetch(board.dataset.collaboratorCollectionEndpoint, { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + const body = await readJson(response); + if (!response.ok) { + throw new Error(body?.detail || "Nao foi possivel criar o colaborador."); + } + + showFeedback("success", body?.message || "Colaborador criado com sucesso."); + form.reset(); + const activeField = form.querySelector('[name="is_active"]'); + if (activeField instanceof HTMLInputElement) { + activeField.checked = true; + } + await loadCollaborators(); + } catch (error) { + showFeedback("danger", error instanceof Error ? error.message : "Erro inesperado ao cadastrar colaborador."); + } finally { + toggleSubmitting(false); + } + }); + + list.addEventListener("click", async (event) => { + const target = event.target; + if (!(target instanceof HTMLElement) || !target.matches("[data-collaborator-toggle]")) { + return; + } + + const collaboratorId = target.dataset.collaboratorId; + const nextState = target.dataset.collaboratorNextState === "true"; + if (!collaboratorId) { + return; + } + + toggleRefreshing(true); + clearFeedback(); + + try { + const response = await fetch(`${board.dataset.collaboratorCollectionEndpoint}/${collaboratorId}/status`, { + method: "PATCH", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ is_active: nextState }), + }); + const body = await readJson(response); + if (!response.ok) { + throw new Error(body?.detail || "Nao foi possivel atualizar o status do colaborador."); + } + + showFeedback("success", body?.message || "Status do colaborador atualizado com sucesso."); + await loadCollaborators(); + } catch (error) { + showFeedback("danger", error instanceof Error ? error.message : "Erro inesperado ao atualizar o colaborador."); + toggleRefreshing(false); + } + }); + + void loadCollaborators(); + + async function loadCollaborators() { + toggleRefreshing(true); + const result = await fetchPanelJson(board.dataset.collaboratorCollectionEndpoint); + + if (result.ok) { + renderCollaborators(result.body); + } else { + list.innerHTML = `

Leitura indisponivel

${escapeHtml(result.message || "Nao foi possivel carregar os colaboradores.")}

`; + setText("[data-collaborator-total]", "0"); + setText("[data-collaborator-active-count]", "0"); + setText("[data-collaborator-inactive-count]", "0"); + showFeedback("warning", result.message || "A sessao atual nao pode consultar os colaboradores."); + } + + toggleRefreshing(false); + } + + function renderCollaborators(payload) { + const collaborators = Array.isArray(payload?.collaborators) ? payload.collaborators : []; + setText("[data-collaborator-total]", String(payload?.total || 0)); + setText("[data-collaborator-active-count]", String(payload?.active_count || 0)); + setText("[data-collaborator-inactive-count]", String(payload?.inactive_count || 0)); + + list.innerHTML = collaborators.length > 0 + ? collaborators.map((item) => { + const statusBadge = item?.is_active + ? "bg-success-subtle text-success-emphasis border border-success-subtle" + : "bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle"; + const actionLabel = item?.is_active ? "Desativar acesso" : "Reativar acesso"; + const buttonClass = item?.is_active ? "btn-outline-secondary" : "btn-outline-dark"; + const lastLogin = item?.last_login_at ? formatDateTime(item.last_login_at) : "Ainda nao acessou"; + return `
${escapeHtml(item?.role || "colaborador")}

${escapeHtml(item?.display_name || "Colaborador")}

${escapeHtml(item?.email || "")}
${item?.is_active ? "Ativo" : "Inativo"}
Ultimo login: ${escapeHtml(lastLogin)}
ID: ${escapeHtml(String(item?.id || "-"))}
`; + }).join("") + : `

Nenhum colaborador cadastrado ainda

Use o formulario ao lado para criar o primeiro colaborador administrativo.

`; + } + + function toggleSubmitting(isLoading) { + submitButton.disabled = isLoading; + submitSpinner.classList.toggle("d-none", !isLoading); + submitLabel.textContent = isLoading ? "Criando..." : "Criar colaborador"; + } + + function toggleRefreshing(isLoading) { + refreshButton.disabled = isLoading; + refreshSpinner.classList.toggle("d-none", !isLoading); + refreshLabel.textContent = isLoading ? "Atualizando..." : "Atualizar lista"; + } + + function clearFeedback() { + feedback.className = "alert d-none rounded-4 mb-4"; + feedback.textContent = ""; + } + + function showFeedback(variant, message) { + feedback.className = `alert alert-${variant} rounded-4 mb-4`; + feedback.textContent = message; + } +} + async function fetchPanelJson(url) { const response = await fetch(url, { credentials: "same-origin", @@ -247,3 +596,17 @@ function escapeHtml(value) { .replaceAll('"', """) .replaceAll("'", "'"); } + +function formatDateTime(value) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return String(value || ""); + } + return parsed.toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/admin_app/view/static/styles/panel.css b/admin_app/view/static/styles/panel.css index 27bc66f..c2c4093 100644 --- a/admin_app/view/static/styles/panel.css +++ b/admin_app/view/static/styles/panel.css @@ -259,3 +259,65 @@ body.admin-view-body { .admin-tool-review-page .admin-hero-card::after { background: radial-gradient(circle, rgba(20, 77, 71, 0.2), transparent 72%); } + +.admin-tool-form-pane, +.admin-tool-preview-card, +.admin-tool-parameter-row { + background: var(--admin-surface-strong); + border: 1px solid var(--admin-line); + border-radius: 1.35rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.admin-tool-form-control { + border: 1px solid rgba(20, 77, 71, 0.12); + background: rgba(255, 255, 255, 0.92); +} + +.admin-tool-form-control:focus { + border-color: rgba(20, 77, 71, 0.32); + box-shadow: 0 0 0 0.25rem rgba(20, 77, 71, 0.12); +} + +.admin-tool-intake-chip-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + max-width: 22rem; +} + +.admin-tool-preview-meta { + display: grid; + gap: 0.5rem; +} + +.admin-tool-preview-stack { + border-top: 1px solid var(--admin-line); + padding-top: 1rem; +} + +.admin-tool-intake-page .admin-hero-card::after { + background: radial-gradient(circle, rgba(193, 106, 51, 0.18), transparent 72%); +} + +.admin-collaborator-page .admin-hero-card::after { + background: radial-gradient(circle, rgba(32, 36, 47, 0.16), transparent 72%); +} + +.admin-collaborator-card, +.admin-collaborator-kpi { + background: var(--admin-surface-strong); + border: 1px solid var(--admin-line); + border-radius: 1.35rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +.admin-collaborator-grid { + display: grid; + gap: 1rem; +} + +.admin-collaborator-meta { + display: grid; + gap: 0.45rem; +} diff --git a/admin_app/view/view_models.py b/admin_app/view/view_models.py index d93716c..76cc422 100644 --- a/admin_app/view/view_models.py +++ b/admin_app/view/view_models.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel class AdminPanelNavigationItem(BaseModel): @@ -104,3 +104,44 @@ class AdminToolReviewPageView(BaseModel): review_notes: tuple[str, ...] approval_notes: tuple[str, ...] activation_notes: tuple[str, ...] + + +class AdminToolIntakeDomainOption(BaseModel): + value: str + label: str + description: str + + +class AdminToolIntakeParameterTypeOption(BaseModel): + value: str + label: str + description: str + + +class AdminToolIntakePageView(BaseModel): + app_name: str + title: str + subtitle: str + environment: str + version: str + dashboard_href: str + review_href: str + intake_endpoint: str + domain_options: tuple[AdminToolIntakeDomainOption, ...] + parameter_type_options: tuple[AdminToolIntakeParameterTypeOption, ...] + naming_rules: tuple[str, ...] + submission_notes: tuple[str, ...] + approval_notes: tuple[str, ...] + + +class AdminCollaboratorManagementPageView(BaseModel): + app_name: str + title: str + subtitle: str + environment: str + version: str + dashboard_href: str + collection_endpoint: str + password_policy_label: str + onboarding_notes: tuple[str, ...] + governance_notes: tuple[str, ...] diff --git a/docs/adr/0001-separate-admin-and-customer-identity.md b/docs/adr/0001-separate-admin-and-customer-identity.md index dd52e49..d8c3e88 100644 --- a/docs/adr/0001-separate-admin-and-customer-identity.md +++ b/docs/adr/0001-separate-admin-and-customer-identity.md @@ -1,4 +1,4 @@ -# ADR 0001 - Separar usuario de atendimento de conta administrativa interna +# ADR 0001 - Separar usuario de atendimento de conta administrativa interna ## Status Accepted @@ -76,9 +76,8 @@ O banco operacional continua com entidades como: ## Papel inicial de permissao A primeira versao deve prever ao menos estes papeis: -- `admin`: gerencia contas internas, aprova e publica tools, altera configuracoes sensiveis -- `staff`: cria drafts, acompanha geracao, revisa resultados e solicita aprovacao -- `viewer`: consulta status e auditoria, sem publicar +- `diretor`: gerencia contas internas, aprova e publica tools, altera configuracoes sensiveis e cadastra novos colaboradores +- `colaborador`: consulta o fluxo operacional do bot, cria drafts de tools e acompanha o andamento ate a aprovacao ## Estrutura tecnica sugerida @@ -106,10 +105,10 @@ A primeira versao deve prever ao menos estes papeis: ## Fluxo alvo de alto nivel 1. `StaffAccount` faz login no painel interno. -2. O usuario interno cria um `ToolDraft` com nome, descricao e parametros. +2. Um `colaborador` cria um `ToolDraft` com nome, descricao e parametros. 3. Um job isolado gera a implementacao e executa validacoes. 4. O resultado fica disponivel para revisao humana. -5. Um `admin` aprova e publica. +5. Um `diretor` revisa, aprova e publica a tool. 6. A tool publicada passa a integrar o registry ativo sem afetar o dominio de identidade do atendimento. ## Impacto nas proximas etapas diff --git a/docs/adr/0002-split-product-and-admin-services.md b/docs/adr/0002-split-product-and-admin-services.md index 1cf4142..4aacfc5 100644 --- a/docs/adr/0002-split-product-and-admin-services.md +++ b/docs/adr/0002-split-product-and-admin-services.md @@ -1,4 +1,4 @@ -# ADR 0002 - Separar o runtime de produto do serviço administrativo +# ADR 0002 - Separar o runtime de produto do serviço administrativo ## Status Accepted @@ -158,10 +158,12 @@ Responsavel por: - auditoria ## Conexao entre dados dos dois servicos -Existem duas estrategias validas para as proximas fases: +A conexao entre `product` e `admin` para relatorios e auditoria operacional segue a seguinte direcao inicial: -1. Banco administrativo consulta dados consolidados do produto por replicacao, ETL ou views dedicadas para relatorios. -2. Banco administrativo recebe snapshots/eventos do produto para alimentar relatorios e auditoria operacional. +1. O `product` permanece como fonte operacional primaria. +2. Um `etl_incremental` fora do hot path exporta apenas datasets e campos aprovados em contrato compartilhado. +3. O `admin` persiste `snapshot_table` sanitizadas e expoe `dedicated_view` para APIs e dashboard. +4. Replica operacional pode aparecer depois apenas como fonte de extracao do ETL, nunca como backend direto do painel. Decisao inicial recomendada: - manter o produto como fonte operacional @@ -244,3 +246,4 @@ Essa escolha fica facilitada pela separacao de servicos, pois: - escolher o modelo definitivo de geracao - definir o formato final de sincronizacao de dados analiticos - definir a UI final do painel administrativo + diff --git a/docs/architecture/admin-credential-strategy.md b/docs/architecture/admin-credential-strategy.md index 4b9526e..b7ff50e 100644 --- a/docs/architecture/admin-credential-strategy.md +++ b/docs/architecture/admin-credential-strategy.md @@ -16,7 +16,7 @@ Nao deve ser usada para identificar cliente do atendimento. Responsabilidades principais do `StaffAccount`: - autenticar acesso ao painel administrativo -- carregar papel de autorizacao (`viewer`, `staff`, `admin`) +- carregar papel de autorizacao (`colaborador`, `diretor`) - auditar quem executou uma acao interna - governar drafts, configuracoes, relatorios e publicacao de tools @@ -97,9 +97,9 @@ Nesta etapa, o runtime administrativo ja faz: - rotacao do refresh token no endpoint de refresh - revogacao da sessao no logout -## Bootstrap do primeiro admin +## Bootstrap do primeiro diretor -O primeiro admin deve nascer por fluxo controlado, nunca por startup automatico. +A primeira conta de diretor deve nascer por fluxo controlado, nunca por startup automatico. Variaveis previstas: @@ -113,15 +113,14 @@ Regras: - o bootstrap deve ser executado por comando explicito no futuro - nao deve criar conta automaticamente ao subir o servico -- o papel padrao do bootstrap e `admin` +- o papel padrao do bootstrap e `diretor` ## Relacao com papeis e permissoes A conta `StaffAccount` continua acoplada a hierarquia compartilhada: -- `viewer` -- `staff` -- `admin` +- `colaborador` +- `diretor` A senha autentica a identidade; o `role` governa autorizacao. @@ -160,5 +159,5 @@ Nesta etapa, o `orquestrador-admin` passa a expor: ## Proximos passos naturais - implementar autorizacao por papel nas demais rotas administrativas -- implementar fluxo explicito de bootstrap do primeiro admin +- implementar fluxo explicito de bootstrap do primeiro diretor - implementar gestao de sessoes revogaveis por tela administrativa diff --git a/shared/contracts/access_control.py b/shared/contracts/access_control.py index 2a5baed..d7f3b7e 100644 --- a/shared/contracts/access_control.py +++ b/shared/contracts/access_control.py @@ -4,9 +4,8 @@ from enum import Enum class StaffRole(str, Enum): - VIEWER = "viewer" - STAFF = "staff" - ADMIN = "admin" + COLABORADOR = "colaborador" + DIRETOR = "diretor" class AdminPermission(str, Enum): @@ -20,30 +19,27 @@ class AdminPermission(str, Enum): MANAGE_STAFF_ACCOUNTS = "manage_staff_accounts" +_LEGACY_ROLE_ALIASES = { + "viewer": StaffRole.COLABORADOR, + "staff": StaffRole.COLABORADOR, + "admin": StaffRole.DIRETOR, +} + _ROLE_HIERARCHY = { - StaffRole.VIEWER: 10, - StaffRole.STAFF: 20, - StaffRole.ADMIN: 30, + StaffRole.COLABORADOR: 10, + StaffRole.DIRETOR: 20, } _ROLE_PERMISSIONS = { - StaffRole.VIEWER: frozenset( - { - AdminPermission.VIEW_SYSTEM, - AdminPermission.VIEW_REPORTS, - AdminPermission.VIEW_AUDIT_LOGS, - } - ), - StaffRole.STAFF: frozenset( + StaffRole.COLABORADOR: frozenset( { AdminPermission.VIEW_SYSTEM, AdminPermission.VIEW_REPORTS, AdminPermission.VIEW_AUDIT_LOGS, AdminPermission.MANAGE_TOOL_DRAFTS, - AdminPermission.REVIEW_TOOL_GENERATIONS, } ), - StaffRole.ADMIN: frozenset( + StaffRole.DIRETOR: frozenset( { AdminPermission.VIEW_SYSTEM, AdminPermission.VIEW_REPORTS, @@ -61,7 +57,11 @@ _ROLE_PERMISSIONS = { def normalize_staff_role(role: StaffRole | str) -> StaffRole: if isinstance(role, StaffRole): return role - return StaffRole(str(role).strip().lower()) + + normalized = str(role).strip().lower() + if normalized in _LEGACY_ROLE_ALIASES: + return _LEGACY_ROLE_ALIASES[normalized] + return StaffRole(normalized) def normalize_admin_permission(permission: AdminPermission | str) -> AdminPermission: diff --git a/tests/test_admin_app_bootstrap.py b/tests/test_admin_app_bootstrap.py index 142e407..f705841 100644 --- a/tests/test_admin_app_bootstrap.py +++ b/tests/test_admin_app_bootstrap.py @@ -1,4 +1,4 @@ -import unittest +import unittest from fastapi.testclient import TestClient @@ -61,9 +61,9 @@ class AdminAppBootstrapTests(unittest.TestCase): (), { "id": 1, - "email": "viewer@empresa.com", - "display_name": "Viewer", - "role": StaffRole.VIEWER, + "email": "colaborador@empresa.com", + "display_name": "Colaborador", + "role": StaffRole.COLABORADOR, "is_active": True, }, )() @@ -87,3 +87,5 @@ class AdminAppBootstrapTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + + diff --git a/tests/test_admin_auth_service.py b/tests/test_admin_auth_service.py index 06ce45c..0cbc7fd 100644 --- a/tests/test_admin_auth_service.py +++ b/tests/test_admin_auth_service.py @@ -1,4 +1,4 @@ -import unittest +import unittest from datetime import datetime, timedelta, timezone from admin_app.core import AdminSecurityService, AdminSettings @@ -87,7 +87,7 @@ class AdminAuthServiceTests(unittest.TestCase): email="admin@empresa.com", display_name="Administrador", password_hash=self.security_service.hash_password("SenhaMuitoSegura!123"), - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) self.account_repository = _FakeStaffAccountRepository(self.account) @@ -113,7 +113,7 @@ class AdminAuthServiceTests(unittest.TestCase): self.assertEqual(session.session_id, 1) self.assertTrue(session.access_token) self.assertTrue(session.refresh_token) - self.assertEqual(session.principal.role, StaffRole.ADMIN) + self.assertEqual(session.principal.role, StaffRole.DIRETOR) self.assertEqual(self.audit_repository.entries[-1].event_type, "staff.login.succeeded") def test_login_failure_creates_audit_entry(self): @@ -219,3 +219,4 @@ class AdminAuthServiceTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_admin_auth_web.py b/tests/test_admin_auth_web.py index 63d7bb8..5101783 100644 --- a/tests/test_admin_auth_web.py +++ b/tests/test_admin_auth_web.py @@ -20,7 +20,7 @@ class _FakeAuthService: id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) return AdminAuthenticatedSession( @@ -39,7 +39,7 @@ class _FakeAuthService: id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) return AdminAuthenticatedSession( @@ -71,7 +71,7 @@ class AdminAuthWebTests(unittest.TestCase): id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) app.dependency_overrides[get_current_staff_context] = lambda: AuthenticatedStaffContext( @@ -79,7 +79,7 @@ class AdminAuthWebTests(unittest.TestCase): id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ), session_id=77, @@ -100,7 +100,7 @@ class AdminAuthWebTests(unittest.TestCase): self.assertEqual(response.json()["session_id"], 77) self.assertEqual(response.json()["token_type"], "bearer") self.assertEqual(response.json()["refresh_token"], "refresh-abc") - self.assertEqual(response.json()["staff_account"]["role"], "admin") + self.assertEqual(response.json()["staff_account"]["role"], "diretor") def test_refresh_returns_rotated_tokens(self): response = self.client.post( @@ -136,15 +136,16 @@ class AdminAuthWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["email"], "admin@empresa.com") - self.assertEqual(response.json()["role"], "admin") + self.assertEqual(response.json()["role"], "diretor") def test_system_access_returns_permissions_for_authenticated_staff(self): response = self.client.get("/system/access", headers={"Authorization": "Bearer token-abc"}) self.assertEqual(response.status_code, 200) self.assertIn("manage_settings", response.json()["permissions"]) - self.assertEqual(response.json()["staff_account"]["role"], "admin") + self.assertEqual(response.json()["staff_account"]["role"], "diretor") if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() + diff --git a/tests/test_admin_authorization_web.py b/tests/test_admin_authorization_web.py index 2af4dc7..9d7ab7e 100644 --- a/tests/test_admin_authorization_web.py +++ b/tests/test_admin_authorization_web.py @@ -34,7 +34,7 @@ class AdminAuthorizationWebTests(unittest.TestCase): app = create_app(AdminSettings(admin_auth_token_secret="test-secret")) app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( id=10, - email="staff@empresa.com", + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", display_name="Equipe Interna", role=role, is_active=True, @@ -51,8 +51,8 @@ class AdminAuthorizationWebTests(unittest.TestCase): self.assertEqual(response.status_code, 401) self.assertEqual(response.json()["detail"], "Autenticacao administrativa obrigatoria.") - def test_viewer_can_access_system_info(self): - client, app = self._build_client_with_role(StaffRole.VIEWER) + def test_colaborador_can_access_system_info(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/system/info", headers={"Authorization": "Bearer token"}) finally: @@ -61,8 +61,8 @@ class AdminAuthorizationWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["service"], "orquestrador-admin") - def test_viewer_can_access_audit_events(self): - client, app = self._build_client_with_role(StaffRole.VIEWER) + def test_colaborador_can_access_audit_events(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/audit/events", headers={"Authorization": "Bearer token"}) finally: @@ -71,8 +71,8 @@ class AdminAuthorizationWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["events"][0]["event_type"], "staff.login.succeeded") - def test_staff_cannot_access_admin_only_capability(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_colaborador_cannot_access_director_only_capability(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/system/admin-capabilities", headers={"Authorization": "Bearer token"}) finally: @@ -84,8 +84,8 @@ class AdminAuthorizationWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'manage_settings'.", ) - def test_admin_can_access_admin_only_capability(self): - client, app = self._build_client_with_role(StaffRole.ADMIN) + def test_diretor_can_access_director_only_capability(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/system/admin-capabilities", headers={"Authorization": "Bearer token"}) finally: @@ -93,8 +93,8 @@ class AdminAuthorizationWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertTrue(response.json()["allowed"]) - self.assertEqual(response.json()["role"], "admin") + self.assertEqual(response.json()["role"], "diretor") if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_admin_collaborators_web.py b/tests/test_admin_collaborators_web.py new file mode 100644 index 0000000..be24ed5 --- /dev/null +++ b/tests/test_admin_collaborators_web.py @@ -0,0 +1,174 @@ +import unittest +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import get_collaborator_management_service, get_current_staff_principal +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class _FakeCollaboratorManagementService: + def __init__(self): + now = datetime(2026, 3, 27, 12, 0, tzinfo=timezone.utc) + self.accounts = { + 31: { + "id": 31, + "email": "colaborador1@empresa.com", + "display_name": "Colaborador Um", + "role": StaffRole.COLABORADOR, + "is_active": True, + "last_login_at": None, + "created_at": now, + "updated_at": now, + }, + 32: { + "id": 32, + "email": "colaborador2@empresa.com", + "display_name": "Colaborador Dois", + "role": StaffRole.COLABORADOR, + "is_active": False, + "last_login_at": None, + "created_at": now, + "updated_at": now, + }, + } + self.next_id = 33 + + def list_collaborators(self) -> dict: + accounts = list(self.accounts.values()) + active_count = sum(1 for account in accounts if account["is_active"]) + return { + "accounts": accounts, + "total": len(accounts), + "active_count": active_count, + "inactive_count": len(accounts) - active_count, + } + + def create_collaborator(self, *, email: str, display_name: str, password: str, is_active: bool, actor_staff_account_id: int, ip_address: str | None, user_agent: str | None) -> dict: + normalized_email = email.strip().lower() + if any(account["email"] == normalized_email for account in self.accounts.values()): + raise ValueError("Ja existe uma conta administrativa com este email.") + now = datetime(2026, 3, 27, 12, 30, tzinfo=timezone.utc) + collaborator = { + "id": self.next_id, + "email": normalized_email, + "display_name": display_name.strip(), + "role": StaffRole.COLABORADOR, + "is_active": is_active, + "last_login_at": None, + "created_at": now, + "updated_at": now, + } + self.accounts[self.next_id] = collaborator + self.next_id += 1 + return collaborator + + def update_collaborator_status(self, *, collaborator_id: int, is_active: bool, actor_staff_account_id: int, ip_address: str | None, user_agent: str | None) -> dict: + collaborator = self.accounts.get(collaborator_id) + if collaborator is None: + raise LookupError("Colaborador administrativo nao encontrado.") + collaborator["is_active"] = is_active + collaborator["updated_at"] = datetime(2026, 3, 27, 13, 0, tzinfo=timezone.utc) + return collaborator + + +class AdminCollaboratorsWebTests(unittest.TestCase): + def _build_client_with_role(self, role: StaffRole) -> tuple[TestClient, object, _FakeCollaboratorManagementService]: + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + service = _FakeCollaboratorManagementService() + app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=9, + email="diretor@empresa.com" if role == StaffRole.DIRETOR else "colaborador@empresa.com", + display_name="Equipe Gestora", + role=role, + is_active=True, + ) + app.dependency_overrides[get_collaborator_management_service] = lambda: service + return TestClient(app), app, service + + def test_colaborador_cannot_access_collaborator_management_routes(self): + client, app, _ = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/colaboradores", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["detail"], "Permissao administrativa insuficiente: 'manage_staff_accounts'.") + + def test_diretor_can_list_collaborators(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.get("/admin/colaboradores", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["total"], 2) + self.assertEqual(payload["active_count"], 1) + self.assertEqual(payload["inactive_count"], 1) + self.assertEqual(payload["collaborators"][0]["role"], "colaborador") + + def test_diretor_can_create_collaborator(self): + client, app, service = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.post( + "/admin/colaboradores", + headers={"Authorization": "Bearer token"}, + json={ + "email": "novo.colaborador@empresa.com", + "display_name": "Novo Colaborador", + "password": "SenhaMuitoSegura!123", + "is_active": True, + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["collaborator"]["email"], "novo.colaborador@empresa.com") + self.assertEqual(payload["collaborator"]["role"], "colaborador") + self.assertEqual(len(service.accounts), 3) + + def test_create_collaborator_rejects_duplicate_email(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.post( + "/admin/colaboradores", + headers={"Authorization": "Bearer token"}, + json={ + "email": "colaborador1@empresa.com", + "display_name": "Duplicado", + "password": "SenhaMuitoSegura!123", + "is_active": True, + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["detail"], "Ja existe uma conta administrativa com este email.") + + def test_diretor_can_update_collaborator_status(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.patch( + "/admin/colaboradores/31/status", + headers={"Authorization": "Bearer token"}, + json={"is_active": False}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertFalse(payload["collaborator"]["is_active"]) + self.assertIn("desativado", payload["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_credential_strategy.py b/tests/test_admin_credential_strategy.py index ed2e9d5..d0bd7cc 100644 --- a/tests/test_admin_credential_strategy.py +++ b/tests/test_admin_credential_strategy.py @@ -16,7 +16,7 @@ class AdminCredentialStrategyTests(unittest.TestCase): self.assertEqual(settings.admin_auth_access_token_ttl_minutes, 30) self.assertEqual(settings.admin_auth_refresh_token_ttl_days, 7) self.assertFalse(settings.admin_bootstrap_enabled) - self.assertEqual(settings.admin_bootstrap_role, "admin") + self.assertEqual(settings.admin_bootstrap_role, "diretor") def test_admin_settings_reject_insecure_password_policy(self): with self.assertRaises(ValidationError): @@ -42,8 +42,8 @@ class AdminCredentialStrategyTests(unittest.TestCase): settings = AdminSettings( admin_auth_password_pepper="secret-pepper", admin_bootstrap_enabled=True, - admin_bootstrap_email="admin@empresa.com", - admin_bootstrap_display_name="Admin Inicial", + admin_bootstrap_email="diretor@empresa.com", + admin_bootstrap_display_name="Diretor Inicial", admin_bootstrap_password="SenhaMuitoSegura!123", ) @@ -53,9 +53,9 @@ class AdminCredentialStrategyTests(unittest.TestCase): self.assertTrue(strategy.password.pepper_configured) self.assertEqual(strategy.tokens.access_token_ttl_minutes, 30) self.assertTrue(strategy.bootstrap.enabled) - self.assertEqual(strategy.bootstrap.email, "admin@empresa.com") + self.assertEqual(strategy.bootstrap.email, "diretor@empresa.com") self.assertTrue(strategy.bootstrap.password_configured) - self.assertEqual(strategy.bootstrap.role, "admin") + self.assertEqual(strategy.bootstrap.role, "diretor") if __name__ == "__main__": diff --git a/tests/test_admin_panel_auth_web.py b/tests/test_admin_panel_auth_web.py index 6bf4130..fa53bc0 100644 --- a/tests/test_admin_panel_auth_web.py +++ b/tests/test_admin_panel_auth_web.py @@ -1,4 +1,4 @@ -import unittest +import unittest from fastapi.testclient import TestClient @@ -21,7 +21,7 @@ class _FakePanelAuthService: id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) return AdminAuthenticatedSession( @@ -40,7 +40,7 @@ class _FakePanelAuthService: id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) return AdminAuthenticatedSession( @@ -60,7 +60,7 @@ class _FakePanelAuthService: id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ), session_id=77, @@ -130,7 +130,7 @@ class AdminPanelAuthWebTests(unittest.TestCase): id=1, email="admin@empresa.com", display_name="Administrador", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ), session_id=77, @@ -161,3 +161,4 @@ class AdminPanelAuthWebTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_admin_panel_collaborators_web.py b/tests/test_admin_panel_collaborators_web.py new file mode 100644 index 0000000..a46c10b --- /dev/null +++ b/tests/test_admin_panel_collaborators_web.py @@ -0,0 +1,201 @@ +import unittest +from datetime import datetime, timezone + +from fastapi.testclient import TestClient + +from admin_app.api.dependencies import ( + get_collaborator_management_service, + get_current_panel_staff_principal, +) +from admin_app.app_factory import create_app +from admin_app.core import AdminSettings, AuthenticatedStaffPrincipal +from shared.contracts import StaffRole + + +class _FakeCollaboratorManagementService: + def __init__(self): + now = datetime(2026, 3, 27, 12, 0, tzinfo=timezone.utc) + self.accounts = { + 41: { + "id": 41, + "email": "colaborador.web1@empresa.com", + "display_name": "Colaborador Web Um", + "role": StaffRole.COLABORADOR, + "is_active": True, + "last_login_at": None, + "created_at": now, + "updated_at": now, + }, + 42: { + "id": 42, + "email": "colaborador.web2@empresa.com", + "display_name": "Colaborador Web Dois", + "role": StaffRole.COLABORADOR, + "is_active": False, + "last_login_at": None, + "created_at": now, + "updated_at": now, + }, + } + self.next_id = 43 + + def list_collaborators(self) -> dict: + accounts = list(self.accounts.values()) + active_count = sum(1 for account in accounts if account["is_active"]) + return { + "accounts": accounts, + "total": len(accounts), + "active_count": active_count, + "inactive_count": len(accounts) - active_count, + } + + def create_collaborator( + self, + *, + email: str, + display_name: str, + password: str, + is_active: bool, + actor_staff_account_id: int, + ip_address: str | None, + user_agent: str | None, + ) -> dict: + normalized_email = email.strip().lower() + if any(account["email"] == normalized_email for account in self.accounts.values()): + raise ValueError("Ja existe uma conta administrativa com este email.") + now = datetime(2026, 3, 27, 12, 30, tzinfo=timezone.utc) + collaborator = { + "id": self.next_id, + "email": normalized_email, + "display_name": display_name.strip(), + "role": StaffRole.COLABORADOR, + "is_active": is_active, + "last_login_at": None, + "created_at": now, + "updated_at": now, + } + self.accounts[self.next_id] = collaborator + self.next_id += 1 + return collaborator + + def update_collaborator_status( + self, + *, + collaborator_id: int, + is_active: bool, + actor_staff_account_id: int, + ip_address: str | None, + user_agent: str | None, + ) -> dict: + collaborator = self.accounts.get(collaborator_id) + if collaborator is None: + raise LookupError("Colaborador administrativo nao encontrado.") + collaborator["is_active"] = is_active + collaborator["updated_at"] = datetime(2026, 3, 27, 13, 0, tzinfo=timezone.utc) + return collaborator + + +class AdminPanelCollaboratorsWebTests(unittest.TestCase): + def _build_client_with_role( + self, + role: StaffRole, + ) -> tuple[TestClient, object, _FakeCollaboratorManagementService]: + app = create_app(AdminSettings(admin_auth_token_secret="test-secret", admin_api_prefix="/admin")) + service = _FakeCollaboratorManagementService() + app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal( + id=12, + email="diretor@empresa.com" if role == StaffRole.DIRETOR else "colaborador@empresa.com", + display_name="Equipe Web", + role=role, + is_active=True, + ) + app.dependency_overrides[get_collaborator_management_service] = lambda: service + return TestClient(app), app, service + + def test_panel_colaborador_cannot_access_collaborator_management_routes(self): + client, app, _ = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/panel/colaboradores") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'manage_staff_accounts'.", + ) + + def test_panel_diretor_can_list_collaborators(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.get("/admin/panel/colaboradores") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["total"], 2) + self.assertEqual(payload["active_count"], 1) + self.assertEqual(payload["inactive_count"], 1) + self.assertEqual(payload["collaborators"][0]["role"], "colaborador") + + def test_panel_diretor_can_create_collaborator(self): + client, app, service = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.post( + "/admin/panel/colaboradores", + json={ + "email": "novo.web@empresa.com", + "display_name": "Novo Colaborador Web", + "password": "SenhaMuitoSegura!123", + "is_active": True, + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 201) + payload = response.json() + self.assertEqual(payload["collaborator"]["email"], "novo.web@empresa.com") + self.assertEqual(payload["collaborator"]["role"], "colaborador") + self.assertEqual(len(service.accounts), 3) + + def test_panel_create_collaborator_rejects_duplicate_email(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.post( + "/admin/panel/colaboradores", + json={ + "email": "colaborador.web1@empresa.com", + "display_name": "Duplicado Web", + "password": "SenhaMuitoSegura!123", + "is_active": True, + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 409) + self.assertEqual( + response.json()["detail"], + "Ja existe uma conta administrativa com este email.", + ) + + def test_panel_diretor_can_update_collaborator_status(self): + client, app, _ = self._build_client_with_role(StaffRole.DIRETOR) + try: + response = client.patch( + "/admin/panel/colaboradores/41/status", + json={"is_active": False}, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertFalse(payload["collaborator"]["is_active"]) + self.assertIn("desativado", payload["message"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_admin_panel_tools_web.py b/tests/test_admin_panel_tools_web.py index 37d8d60..d4cbe5d 100644 --- a/tests/test_admin_panel_tools_web.py +++ b/tests/test_admin_panel_tools_web.py @@ -23,15 +23,15 @@ class AdminPanelToolsWebTests(unittest.TestCase): ) app.dependency_overrides[get_current_panel_staff_principal] = lambda: AuthenticatedStaffPrincipal( id=21, - email="staff@empresa.com", + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", display_name="Equipe Web", role=role, is_active=True, ) return TestClient(app), app - def test_panel_tools_overview_is_available_for_staff_session(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_panel_tools_overview_is_available_for_colaborador_session(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/panel/tools/overview") finally: @@ -41,9 +41,61 @@ class AdminPanelToolsWebTests(unittest.TestCase): payload = response.json() self.assertEqual(payload["mode"], "bootstrap_catalog") self.assertIn("/admin/panel/tools/contracts", [item["href"] for item in payload["actions"]]) + self.assertIn("/admin/panel/tools/drafts/intake", [item["href"] for item in payload["actions"]]) - def test_panel_tools_review_queue_is_available_for_staff_session(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_panel_tool_intake_accepts_validated_preview_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.post( + "/admin/panel/tools/drafts/intake", + json={ + "domain": "vendas", + "tool_name": "consultar_vendas_periodo", + "display_name": "Consultar vendas por periodo", + "description": "Consulta vendas consolidadas por periodo informado no painel.", + "business_goal": "Ajudar o time interno a acompanhar o desempenho comercial com mais agilidade.", + "parameters": [ + { + "name": "periodo_inicio", + "parameter_type": "string", + "description": "Data inicial usada no filtro.", + "required": True, + }, + { + "name": "periodo_fim", + "parameter_type": "string", + "description": "Data final usada no filtro.", + "required": True, + }, + ], + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["storage_status"], "validated_preview") + self.assertEqual(payload["draft_preview"]["status"], "draft") + self.assertEqual(payload["draft_preview"]["tool_name"], "consultar_vendas_periodo") + self.assertTrue(payload["draft_preview"]["requires_director_approval"]) + self.assertEqual(len(payload["draft_preview"]["parameters"]), 2) + + def test_panel_tools_review_queue_requires_director_session(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/panel/tools/review-queue") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'review_tool_generations'.", + ) + + def test_panel_tools_review_queue_is_available_for_diretor_session(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/panel/tools/review-queue") finally: @@ -52,8 +104,8 @@ class AdminPanelToolsWebTests(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.json()["queue_mode"], "bootstrap_empty_state") - def test_panel_tools_publications_require_admin_publication_permission(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_panel_tools_publications_require_director_publication_permission(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/panel/tools/publications") finally: @@ -65,8 +117,8 @@ class AdminPanelToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'publish_tools'.", ) - def test_panel_tools_publications_return_catalog_for_admin_session(self): - client, app = self._build_client_with_role(StaffRole.ADMIN) + def test_panel_tools_publications_return_catalog_for_diretor_session(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/panel/tools/publications") finally: diff --git a/tests/test_admin_security_service.py b/tests/test_admin_security_service.py index 75215eb..b6d69d1 100644 --- a/tests/test_admin_security_service.py +++ b/tests/test_admin_security_service.py @@ -27,7 +27,7 @@ class AdminSecurityServiceTests(unittest.TestCase): id=7, email="admin@empresa.com", display_name="Admin", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) token = self.security_service.issue_access_token(principal, session_id=99) @@ -36,7 +36,7 @@ class AdminSecurityServiceTests(unittest.TestCase): self.assertEqual(claims.sub, "7") self.assertEqual(claims.sid, 99) self.assertEqual(claims.email, "admin@empresa.com") - self.assertEqual(claims.role, StaffRole.ADMIN) + self.assertEqual(claims.role, StaffRole.DIRETOR) self.assertEqual(claims.token_type, "access") def test_refresh_token_hash_is_stable_for_same_token(self): @@ -54,3 +54,4 @@ class AdminSecurityServiceTests(unittest.TestCase): if __name__ == "__main__": unittest.main() + diff --git a/tests/test_admin_system_configuration_web.py b/tests/test_admin_system_configuration_web.py index f160fe6..854b52e 100644 --- a/tests/test_admin_system_configuration_web.py +++ b/tests/test_admin_system_configuration_web.py @@ -1,4 +1,4 @@ -import unittest +import unittest from fastapi.testclient import TestClient @@ -33,7 +33,7 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): return TestClient(app), app def test_configuration_routes_require_manage_settings_permission(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/system/configuration", headers={"Authorization": "Bearer token"}) finally: @@ -64,9 +64,9 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): admin_bootstrap_email="bootstrap@empresa.com", admin_bootstrap_display_name="Bootstrap Admin", admin_bootstrap_password="SenhaMuitoSegura!123", - admin_bootstrap_role="admin", + admin_bootstrap_role="diretor", ) - client, app = self._build_client_with_role(StaffRole.ADMIN, settings) + client, app = self._build_client_with_role(StaffRole.DIRETOR, settings) try: response = client.get("/admin/system/configuration", headers={"Authorization": "Bearer token"}) finally: @@ -93,7 +93,7 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): admin_environment="production", admin_debug=False, ) - client, app = self._build_client_with_role(StaffRole.ADMIN, settings) + client, app = self._build_client_with_role(StaffRole.DIRETOR, settings) try: response = client.get("/admin/system/configuration/runtime", headers={"Authorization": "Bearer token"}) finally: @@ -114,9 +114,9 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): admin_auth_token_issuer="admin-runtime", admin_auth_refresh_token_bytes=48, admin_bootstrap_enabled=True, - admin_bootstrap_role="admin", + admin_bootstrap_role="diretor", ) - client, app = self._build_client_with_role(StaffRole.ADMIN, settings) + client, app = self._build_client_with_role(StaffRole.DIRETOR, settings) try: response = client.get("/admin/system/configuration/security", headers={"Authorization": "Bearer token"}) finally: @@ -127,8 +127,10 @@ class AdminSystemConfigurationWebTests(unittest.TestCase): self.assertEqual(security["password"]["min_length"], 14) self.assertEqual(security["tokens"]["issuer"], "admin-runtime") self.assertEqual(security["tokens"]["refresh_token_bytes"], 48) - self.assertEqual(security["bootstrap"]["role"], "admin") + self.assertEqual(security["bootstrap"]["role"], "diretor") if __name__ == "__main__": unittest.main() + + diff --git a/tests/test_admin_tools_web.py b/tests/test_admin_tools_web.py index fd8ed3d..6e09dd6 100644 --- a/tests/test_admin_tools_web.py +++ b/tests/test_admin_tools_web.py @@ -23,28 +23,15 @@ class AdminToolsWebTests(unittest.TestCase): ) app.dependency_overrides[get_current_staff_principal] = lambda: AuthenticatedStaffPrincipal( id=11, - email="staff@empresa.com", + email="colaborador@empresa.com" if role == StaffRole.COLABORADOR else "diretor@empresa.com", display_name="Equipe de Tools", role=role, is_active=True, ) return TestClient(app), app - def test_tools_overview_requires_manage_tool_drafts_permission(self): - client, app = self._build_client_with_role(StaffRole.VIEWER) - try: - response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"}) - finally: - app.dependency_overrides.clear() - - self.assertEqual(response.status_code, 403) - self.assertEqual( - response.json()["detail"], - "Permissao administrativa insuficiente: 'manage_tool_drafts'.", - ) - - def test_tools_overview_returns_metrics_workflow_and_actions(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_tools_overview_returns_metrics_workflow_and_actions_for_colaborador(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/overview", headers={"Authorization": "Bearer token"}) finally: @@ -57,10 +44,11 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["metrics"][0]["value"], "18") self.assertIn("active", [item["code"] for item in payload["workflow"]]) self.assertIn("/admin/tools/contracts", [item["href"] for item in payload["actions"]]) + self.assertIn("/admin/tools/drafts/intake", [item["href"] for item in payload["actions"]]) self.assertIn("ToolDraft", payload["next_steps"][0]) def test_tools_contracts_return_shared_contract_snapshot(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/contracts", headers={"Authorization": "Bearer token"}) finally: @@ -75,7 +63,7 @@ class AdminToolsWebTests(unittest.TestCase): self.assertIn("published_tool", payload["publication_fields"]) def test_tools_drafts_return_empty_state_until_persistence_exists(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/drafts", headers={"Authorization": "Bearer token"}) finally: @@ -87,8 +75,54 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["drafts"], []) self.assertEqual(payload["supported_statuses"], ["draft"]) - def test_tools_review_queue_is_available_for_staff(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_tools_draft_intake_returns_validated_preview(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.post( + "/admin/tools/drafts/intake", + headers={"Authorization": "Bearer token"}, + json={ + "domain": "orquestracao", + "tool_name": "priorizar_contato_quente", + "display_name": "Priorizar contato quente", + "description": "Classifica contatos mais quentes para orientar o proximo passo do atendimento.", + "business_goal": "Dar mais foco comercial ao time interno ao identificar oportunidades mais urgentes.", + "parameters": [ + { + "name": "score_interesse", + "parameter_type": "number", + "description": "Pontuacao atual de interesse do lead.", + "required": True, + } + ], + }, + ) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["storage_status"], "validated_preview") + self.assertEqual(payload["draft_preview"]["status"], "draft") + self.assertEqual(payload["draft_preview"]["domain"], "orquestracao") + self.assertTrue(payload["draft_preview"]["requires_director_approval"]) + self.assertGreaterEqual(len(payload["warnings"]), 1) + + def test_tools_review_queue_requires_director_review_permission(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) + try: + response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 403) + self.assertEqual( + response.json()["detail"], + "Permissao administrativa insuficiente: 'review_tool_generations'.", + ) + + def test_tools_review_queue_is_available_for_diretor(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/tools/review-queue", headers={"Authorization": "Bearer token"}) finally: @@ -100,8 +134,8 @@ class AdminToolsWebTests(unittest.TestCase): self.assertEqual(payload["items"], []) self.assertIn("validated", payload["supported_statuses"]) - def test_tools_publications_require_publish_tools_permission(self): - client, app = self._build_client_with_role(StaffRole.STAFF) + def test_tools_publications_require_director_publication_permission(self): + client, app = self._build_client_with_role(StaffRole.COLABORADOR) try: response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) finally: @@ -113,8 +147,8 @@ class AdminToolsWebTests(unittest.TestCase): "Permissao administrativa insuficiente: 'publish_tools'.", ) - def test_tools_publications_return_bootstrap_catalog_for_admin(self): - client, app = self._build_client_with_role(StaffRole.ADMIN) + def test_tools_publications_return_bootstrap_catalog_for_diretor(self): + client, app = self._build_client_with_role(StaffRole.DIRETOR) try: response = client.get("/admin/tools/publications", headers={"Authorization": "Bearer token"}) finally: diff --git a/tests/test_admin_view_bootstrap.py b/tests/test_admin_view_bootstrap.py index e163a7f..44404e4 100644 --- a/tests/test_admin_view_bootstrap.py +++ b/tests/test_admin_view_bootstrap.py @@ -1,4 +1,4 @@ -import unittest +import unittest from fastapi.testclient import TestClient @@ -8,13 +8,13 @@ from admin_app.core import AdminSettings, AuthenticatedStaffContext, Authenticat from shared.contracts import StaffRole -def _build_panel_context() -> AuthenticatedStaffContext: +def _build_panel_context(role: StaffRole = StaffRole.DIRETOR) -> AuthenticatedStaffContext: return AuthenticatedStaffContext( principal=AuthenticatedStaffPrincipal( - id=7, - email="admin@empresa.com", - display_name="Administrador", - role=StaffRole.ADMIN, + id=7 if role == StaffRole.DIRETOR else 8, + email="diretor@empresa.com" if role == StaffRole.DIRETOR else "colaborador@empresa.com", + display_name="Administrador" if role == StaffRole.DIRETOR else "Colaborador", + role=role, is_active=True, ), session_id=77, @@ -72,9 +72,9 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertNotIn("Voltar ao dashboard", response.text) self.assertNotIn("/panel/tools/review", response.text) - def test_admin_dashboard_renders_bootstrap_dashboard_when_session_exists(self): + def test_admin_dashboard_renders_bootstrap_dashboard_when_director_session_exists(self): app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) - app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context() + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context(StaffRole.DIRETOR) client = TestClient(app) try: response = client.get("/panel/admin") @@ -85,13 +85,54 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn("text/html", response.headers["content-type"]) self.assertIn("Painel Administrativo", response.text) self.assertIn("Dashboard do administrador", response.text) - self.assertIn("Areas do sistema", response.text) - self.assertIn("Entradas claras para as areas protegidas", response.text) + self.assertIn("Cadastro de tools", response.text) self.assertIn("Revisao de tools", response.text) + self.assertIn("Gestao de colaboradores", response.text) + self.assertIn("/panel/tools/new", response.text) self.assertIn("/panel/tools/review", response.text) + self.assertIn("/panel/colaboradores/gestao", response.text) self.assertIn("/panel/assets/styles/panel.css", response.text) self.assertNotIn("API pronta para ser plugada na UI", response.text) + def test_admin_dashboard_hides_collaborator_management_for_colaborador_session(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context(StaffRole.COLABORADOR) + client = TestClient(app) + try: + response = client.get("/panel/admin") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + self.assertNotIn("/panel/colaboradores/gestao", response.text) + self.assertNotIn("Gerir equipe", response.text) + + def test_tool_intake_page_redirects_to_login_without_session(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + client = TestClient(app) + + response = client.get("/panel/tools/new", follow_redirects=False) + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers["location"].endswith("/login")) + + def test_tool_intake_page_renders_real_form_when_session_exists(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context() + client = TestClient(app) + try: + response = client.get("/panel/tools/new") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + self.assertIn("Cadastro de nova tool", response.text) + self.assertIn('data-admin-tool-intake="true"', response.text) + self.assertIn('data-intake-endpoint="/panel/tools/drafts/intake"', response.text) + self.assertIn('data-admin-tool-intake-form="true"', response.text) + self.assertIn("Adicionar parametro", response.text) + self.assertIn("Validar pre-cadastro", response.text) + def test_tool_review_page_redirects_to_login_without_session(self): app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) client = TestClient(app) @@ -119,21 +160,65 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn('data-publications-endpoint="/panel/tools/publications"', response.text) self.assertNotIn("Abrir login administrativo", response.text) + def test_collaborator_management_page_redirects_to_login_without_session(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + client = TestClient(app) + + response = client.get("/panel/colaboradores/gestao", follow_redirects=False) + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers["location"].endswith("/login")) + + def test_collaborator_management_page_redirects_colaborador_to_dashboard(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context(StaffRole.COLABORADOR) + client = TestClient(app) + try: + response = client.get("/panel/colaboradores/gestao", follow_redirects=False) + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 302) + self.assertTrue(response.headers["location"].endswith("/panel/admin")) + + def test_collaborator_management_page_renders_director_board_when_session_exists(self): + app = create_app(AdminSettings(admin_app_name="Admin Interno", admin_version="1.4.0")) + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context(StaffRole.DIRETOR) + client = TestClient(app) + try: + response = client.get("/panel/colaboradores/gestao") + finally: + app.dependency_overrides.clear() + + self.assertEqual(response.status_code, 200) + self.assertIn("Gestao de colaboradores", response.text) + self.assertIn('data-admin-collaborator-board="true"', response.text) + self.assertIn('data-collaborator-collection-endpoint="/panel/colaboradores"', response.text) + self.assertIn('data-admin-collaborator-form="true"', response.text) + self.assertIn("Criar colaborador", response.text) + self.assertIn("Atualizar lista", response.text) + def test_prefixed_panel_routes_apply_auth_gate(self): app = create_app(AdminSettings(admin_api_prefix="/admin")) client = TestClient(app) panel_response = client.get("/admin/panel", follow_redirects=False) login_response = client.get("/admin/login") + intake_response = client.get("/admin/panel/tools/new", follow_redirects=False) review_response = client.get("/admin/panel/tools/review", follow_redirects=False) + collaborator_response = client.get("/admin/panel/colaboradores/gestao", follow_redirects=False) css_response = client.get("/admin/panel/assets/styles/panel.css") self.assertEqual(panel_response.status_code, 302) self.assertEqual(login_response.status_code, 200) + self.assertEqual(intake_response.status_code, 302) self.assertEqual(review_response.status_code, 302) + self.assertEqual(collaborator_response.status_code, 302) self.assertEqual(css_response.status_code, 200) self.assertTrue(panel_response.headers["location"].endswith("/admin/login")) + self.assertTrue(intake_response.headers["location"].endswith("/admin/login")) self.assertTrue(review_response.headers["location"].endswith("/admin/login")) + self.assertTrue(collaborator_response.headers["location"].endswith("/admin/login")) self.assertIn('data-auth-endpoint="/admin/panel/auth/login"', login_response.text) self.assertIn('data-dashboard-href="http://testserver/admin/panel/admin"', login_response.text) self.assertNotIn('data-session-endpoint=', login_response.text) @@ -141,23 +226,31 @@ class AdminViewBootstrapTests(unittest.TestCase): self.assertIn("/admin/panel/assets/styles/panel.css", login_response.text) self.assertIn("--admin-bg", css_response.text) - def test_prefixed_admin_dashboard_and_review_render_when_session_exists(self): + def test_prefixed_admin_pages_render_when_director_session_exists(self): app = create_app(AdminSettings(admin_api_prefix="/admin")) - app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context() + app.dependency_overrides[get_optional_panel_staff_context] = lambda: _build_panel_context(StaffRole.DIRETOR) client = TestClient(app) try: panel_response = client.get("/admin/panel/admin") + intake_response = client.get("/admin/panel/tools/new") review_response = client.get("/admin/panel/tools/review") + collaborator_response = client.get("/admin/panel/colaboradores/gestao") finally: app.dependency_overrides.clear() self.assertEqual(panel_response.status_code, 200) + self.assertEqual(intake_response.status_code, 200) self.assertEqual(review_response.status_code, 200) - self.assertIn("Dashboard do administrador", panel_response.text) - self.assertIn("/admin/panel/tools/review", panel_response.text) + self.assertEqual(collaborator_response.status_code, 200) + self.assertIn("/admin/panel/tools/new", panel_response.text) + self.assertIn("/admin/panel/colaboradores/gestao", panel_response.text) + self.assertIn('data-intake-endpoint="/admin/panel/tools/drafts/intake"', intake_response.text) self.assertIn('data-overview-endpoint="/admin/panel/tools/overview"', review_response.text) self.assertIn('data-publications-endpoint="/admin/panel/tools/publications"', review_response.text) + self.assertIn('data-collaborator-collection-endpoint="/admin/panel/colaboradores"', collaborator_response.text) if __name__ == "__main__": unittest.main() + + diff --git a/tests/test_staff_account_model.py b/tests/test_staff_account_model.py index 2d76c26..6c83676 100644 --- a/tests/test_staff_account_model.py +++ b/tests/test_staff_account_model.py @@ -16,32 +16,28 @@ class StaffAccountModelTests(unittest.TestCase): self.assertIn("created_at", StaffAccount.__table__.columns) self.assertIn("updated_at", StaffAccount.__table__.columns) - def test_staff_account_role_column_uses_shared_hierarchy_values(self): + def test_staff_account_role_column_normalizes_portuguese_and_legacy_values(self): role_column = StaffAccount.__table__.columns["role"] - enum_values = tuple(role_column.type.enums) - - self.assertEqual( - enum_values, - ( - StaffRole.VIEWER.value, - StaffRole.STAFF.value, - StaffRole.ADMIN.value, - ), - ) - def test_staff_account_can_hold_admin_identity_fields(self): + self.assertEqual(role_column.default.arg, StaffRole.COLABORADOR) + self.assertEqual(role_column.type.process_bind_param(StaffRole.DIRETOR, None), "diretor") + self.assertEqual(role_column.type.process_bind_param("admin", None), "diretor") + self.assertEqual(role_column.type.process_result_value("staff", None), StaffRole.COLABORADOR) + self.assertEqual(role_column.type.process_result_value("diretor", None), StaffRole.DIRETOR) + + def test_staff_account_can_hold_director_identity_fields(self): account = StaffAccount( - email="admin@empresa.com", - display_name="Administrador Interno", + email="diretor@empresa.com", + display_name="Diretor Interno", password_hash="hashed-secret", - role=StaffRole.ADMIN, + role=StaffRole.DIRETOR, is_active=True, ) - self.assertEqual(account.email, "admin@empresa.com") - self.assertEqual(account.display_name, "Administrador Interno") + self.assertEqual(account.email, "diretor@empresa.com") + self.assertEqual(account.display_name, "Diretor Interno") self.assertEqual(account.password_hash, "hashed-secret") - self.assertEqual(account.role, StaffRole.ADMIN) + self.assertEqual(account.role, StaffRole.DIRETOR) self.assertTrue(account.is_active)