feat(admin): concluir fluxo interno do painel administrativo

Finaliza a camada operacional do painel com login protegido, dashboard interna separada, tela real de cadastro de novas tools e superficie completa de revisao para o fluxo administrativo.

Tambem adota os papeis em portugues entre colaborador e diretor, adiciona gestao de colaboradores com auditoria e reforca a navegacao para que o usuario so acesse modulos internos depois da autenticacao.
feat/self-evolving-tools-foundation
parent e210b56b37
commit bd662f35fa

@ -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))

@ -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)

@ -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),
)

@ -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),
)

@ -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}"

@ -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",

@ -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

@ -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),
),
)

@ -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()

@ -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)

@ -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)

@ -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",
]

@ -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,
*,

@ -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,
}

@ -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

@ -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'''<div class="col-12 col-md-6"><article class="admin-tool-inline-note rounded-4 p-3 h-100"><div class="fw-semibold mb-2">{escape(item.label)}</div><div class="small text-secondary">{escape(item.description)}</div></article></div>'''
for item in view.domain_options
)
parameter_type_badges_markup = "\n".join(
f'''<span class="badge rounded-pill bg-body-tertiary text-secondary border">{escape(item.label)}</span>'''
for item in view.parameter_type_options
)
domain_select_options = "\n".join(
f'''<option value="{escape(item.value, quote=True)}">{escape(item.label)}</option>'''
for item in view.domain_options
)
parameter_select_options = _render_tool_parameter_options(view.parameter_type_options)
return f'''<!DOCTYPE html>
<html lang="pt-BR" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(view.title)}</title>
<meta name="description" content="{escape(view.subtitle)}">
<link rel="stylesheet" href="{BOOTSTRAP_CSS_HREF}">
<link rel="stylesheet" href="{escape(css_href, quote=True)}">
</head>
<body class="admin-view-body admin-tool-intake-page">
<div class="container-xxl py-4 py-lg-5" data-admin-tool-intake="true" data-intake-endpoint="{escape(view.intake_endpoint, quote=True)}">
<div class="row g-4 align-items-start">
<aside class="col-12 col-xl-4 col-xxl-3">
<div class="card border-0 shadow-sm admin-shell-card admin-sidebar-sticky">
<div class="card-body p-4">
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge rounded-pill text-bg-dark">Cadastro guiado</span>
<span class="badge rounded-pill bg-body-tertiary text-secondary border">Draft real</span>
</div>
<h1 class="display-6 fw-semibold mb-3">{escape(view.title)}</h1>
<p class="text-secondary mb-4">{escape(view.subtitle)}</p>
<div class="d-grid gap-2 mb-4 admin-quick-actions">
<a class="btn btn-dark rounded-pill" href="{escape(view.dashboard_href, quote=True)}">Voltar ao dashboard</a>
<a class="btn btn-outline-dark rounded-pill" href="{escape(view.review_href, quote=True)}">Abrir revisao</a>
</div>
<div class="admin-runtime-block p-3 mb-3">
<p class="text-uppercase small fw-semibold text-secondary mb-3">Resumo do runtime</p>
<div class="d-grid gap-3 small">
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Aplicacao</span>
<strong class="text-end">{escape(view.app_name)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Ambiente</span>
<strong class="text-end text-uppercase">{escape(view.environment)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Versao</span>
<strong class="text-end">{escape(view.version)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Fluxo</span>
<strong class="text-end">Colaborador -> Diretor</strong>
</div>
</div>
</div>
<div class="admin-tool-review-note p-4 mb-3">
<p class="text-uppercase small fw-semibold text-secondary mb-2">Regras de nomeacao</p>
<ul class="small text-secondary ps-3 mb-0">
{naming_rules_markup}
</ul>
</div>
<div class="admin-tool-review-note p-4">
<p class="text-uppercase small fw-semibold text-secondary mb-2">Como a aprovacao funciona</p>
<ul class="small text-secondary ps-3 mb-0">
{approval_notes_markup}
</ul>
</div>
</div>
</div>
</aside>
<section class="col-12 col-xl-8 col-xxl-9">
<div class="card border-0 shadow-sm admin-hero-card overflow-hidden mb-4">
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge rounded-pill bg-white text-dark border">Nova tool</span>
<span class="badge rounded-pill bg-info-subtle text-info-emphasis border border-info-subtle">Preview validado</span>
</div>
<h2 class="display-5 fw-semibold mb-3">Cadastrar uma nova tool com contexto operacional</h2>
<p class="lead text-secondary mb-0">
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.
</p>
</div>
<div class="admin-tool-intake-chip-group">
{parameter_type_badges_markup}
</div>
</div>
</div>
</div>
<div class="alert d-none rounded-4 mb-4" id="admin-tool-intake-feedback" role="status"></div>
<div class="row g-4">
<div class="col-12 col-xxl-7">
<div class="card border-0 shadow-sm admin-surface-card h-100">
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Formulario principal</p>
<h3 class="h3 fw-semibold mb-2">Preencher os dados da nova tool</h3>
<p class="text-secondary mb-0">O objetivo aqui e validar estrutura, objetivo operacional e parametros antes da persistencia definitiva.</p>
</div>
</div>
<form data-admin-tool-intake-form="true" class="vstack gap-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold" for="tool-domain">Dominio</label>
<select class="form-select form-select-lg rounded-4 admin-tool-form-control" id="tool-domain" name="domain" required>
<option value="">Selecione</option>
{domain_select_options}
</select>
</div>
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold" for="tool-name">Nome tecnico</label>
<input class="form-control form-control-lg rounded-4 admin-tool-form-control" id="tool-name" name="tool_name" type="text" placeholder="consultar_vendas_periodo" required>
</div>
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold" for="tool-display-name">Nome de exibicao</label>
<input class="form-control form-control-lg rounded-4 admin-tool-form-control" id="tool-display-name" name="display_name" type="text" placeholder="Consultar vendas por periodo" required>
</div>
<div class="col-12">
<label class="form-label fw-semibold" for="tool-description">Descricao operacional</label>
<textarea class="form-control rounded-4 admin-tool-form-control" id="tool-description" name="description" rows="3" placeholder="Explique o que a tool faz e em que contexto o bot deve aciona-la." required></textarea>
</div>
<div class="col-12">
<label class="form-label fw-semibold" for="tool-business-goal">Objetivo de negocio</label>
<textarea class="form-control rounded-4 admin-tool-form-control" id="tool-business-goal" name="business_goal" rows="3" placeholder="Descreva o ganho operacional esperado com essa tool." required></textarea>
</div>
</div>
<div class="admin-tool-form-pane rounded-4 p-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Parametros</p>
<h4 class="h5 fw-semibold mb-1">Estrutura de entrada da tool</h4>
<p class="text-secondary mb-0">Adicione somente os parametros realmente necessarios para a decisao do bot.</p>
</div>
<button class="btn btn-outline-dark rounded-pill" type="button" data-add-parameter-row>Adicionar parametro</button>
</div>
<div class="vstack gap-3" data-parameter-list>
<div class="admin-tool-parameter-row rounded-4 p-3" data-parameter-row>
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-3">
<label class="form-label fw-semibold">Nome</label>
<input class="form-control rounded-4 admin-tool-form-control" name="parameter_name" type="text" placeholder="periodo_inicio">
</div>
<div class="col-12 col-lg-3">
<label class="form-label fw-semibold">Tipo</label>
<select class="form-select rounded-4 admin-tool-form-control" name="parameter_type">
{parameter_select_options}
</select>
</div>
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold">Descricao</label>
<input class="form-control rounded-4 admin-tool-form-control" name="parameter_description" type="text" placeholder="Data inicial usada no filtro">
</div>
<div class="col-8 col-lg-1">
<div class="form-check form-switch pt-4">
<input class="form-check-input" type="checkbox" role="switch" name="parameter_required" checked>
<label class="form-check-label small text-secondary">Obrig.</label>
</div>
</div>
<div class="col-4 col-lg-1 text-end">
<button class="btn btn-link text-danger text-decoration-none px-0" type="button" data-remove-parameter-row>Remover</button>
</div>
</div>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-3">
<button class="btn btn-dark btn-lg rounded-pill px-4 d-inline-flex align-items-center gap-2" type="submit">
<span data-intake-submit-label>Validar pre-cadastro</span>
<span class="spinner-border spinner-border-sm d-none" data-intake-submit-spinner aria-hidden="true"></span>
</button>
<a class="btn btn-outline-secondary btn-lg rounded-pill px-4" href="{escape(view.review_href, quote=True)}">Ir para revisao</a>
</div>
</form>
<template id="admin-tool-parameter-row-template">
<div class="admin-tool-parameter-row rounded-4 p-3" data-parameter-row>
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-3">
<label class="form-label fw-semibold">Nome</label>
<input class="form-control rounded-4 admin-tool-form-control" name="parameter_name" type="text" placeholder="novo_parametro">
</div>
<div class="col-12 col-lg-3">
<label class="form-label fw-semibold">Tipo</label>
<select class="form-select rounded-4 admin-tool-form-control" name="parameter_type">
{parameter_select_options}
</select>
</div>
<div class="col-12 col-lg-4">
<label class="form-label fw-semibold">Descricao</label>
<input class="form-control rounded-4 admin-tool-form-control" name="parameter_description" type="text" placeholder="Descricao do parametro">
</div>
<div class="col-8 col-lg-1">
<div class="form-check form-switch pt-4">
<input class="form-check-input" type="checkbox" role="switch" name="parameter_required" checked>
<label class="form-check-label small text-secondary">Obrig.</label>
</div>
</div>
<div class="col-4 col-lg-1 text-end">
<button class="btn btn-link text-danger text-decoration-none px-0" type="button" data-remove-parameter-row>Remover</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="col-12 col-xxl-5">
<div class="card border-0 shadow-sm admin-surface-card h-100">
<div class="card-body p-4 p-lg-5 d-flex flex-column gap-4">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Dominios sugeridos</p>
<div class="row g-3">{domain_cards_markup}</div>
</div>
<div class="admin-tool-review-note p-4">
<p class="text-uppercase small fw-semibold text-secondary mb-2">Orientacoes da fase atual</p>
<ul class="small text-secondary ps-3 mb-0">
{submission_notes_markup}
</ul>
</div>
<div class="admin-tool-form-pane rounded-4 p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Preview do draft</p>
<h3 class="h4 fw-semibold mb-1">Resultado da validacao</h3>
</div>
<span class="badge rounded-pill bg-body-tertiary text-secondary border" data-tool-intake-storage-status>Aguardando</span>
</div>
<div data-tool-intake-preview>
<div class="admin-tool-empty-state rounded-4 p-4">
<h4 class="h5 fw-semibold mb-2">Nenhum pre-cadastro validado ainda</h4>
<p class="text-secondary mb-0">Assim que o formulario for validado, o resumo do draft aparece aqui com avisos e proximos passos.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<script src="{BOOTSTRAP_JS_HREF}" defer></script>
<script src="{escape(js_href, quote=True)}" defer></script>
</body>
</html>
'''
def _render_tool_parameter_options(items: tuple[AdminToolIntakeParameterTypeOption, ...]) -> str:
return "\n".join(
f'<option value="{escape(item.value, quote=True)}">{escape(item.label)}</option>'
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'''<!DOCTYPE html>
<html lang="pt-BR" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(view.title)}</title>
<meta name="description" content="{escape(view.subtitle)}">
<link rel="stylesheet" href="{BOOTSTRAP_CSS_HREF}">
<link rel="stylesheet" href="{escape(css_href, quote=True)}">
</head>
<body class="admin-view-body admin-collaborator-page">
<div class="container-xxl py-4 py-lg-5" data-admin-collaborator-board="true" data-collaborator-collection-endpoint="{escape(view.collection_endpoint, quote=True)}">
<div class="row g-4 align-items-start">
<aside class="col-12 col-xl-4 col-xxl-3">
<div class="card border-0 shadow-sm admin-shell-card admin-sidebar-sticky">
<div class="card-body p-4">
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge rounded-pill text-bg-dark">Diretor</span>
<span class="badge rounded-pill bg-body-tertiary text-secondary border">Equipe interna</span>
</div>
<h1 class="display-6 fw-semibold mb-3">{escape(view.title)}</h1>
<p class="text-secondary mb-4">{escape(view.subtitle)}</p>
<div class="d-grid gap-2 mb-4 admin-quick-actions">
<a class="btn btn-dark rounded-pill" href="{escape(view.dashboard_href, quote=True)}">Voltar ao dashboard</a>
<a class="btn btn-outline-dark rounded-pill" href="#collaborator-form-card">Cadastrar colaborador</a>
</div>
<div class="admin-runtime-block p-3 mb-3">
<p class="text-uppercase small fw-semibold text-secondary mb-3">Contexto atual</p>
<div class="d-grid gap-3 small">
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Aplicacao</span>
<strong class="text-end">{escape(view.app_name)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Ambiente</span>
<strong class="text-end text-uppercase">{escape(view.environment)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Versao</span>
<strong class="text-end">{escape(view.version)}</strong>
</div>
<div class="d-flex justify-content-between gap-3">
<span class="text-secondary">Papel exigido</span>
<strong class="text-end">Diretor</strong>
</div>
</div>
</div>
<div class="admin-tool-review-note p-4 mb-3">
<p class="text-uppercase small fw-semibold text-secondary mb-2">Onboarding da equipe</p>
<ul class="small text-secondary ps-3 mb-0">
{onboarding_notes_markup}
</ul>
</div>
<div class="admin-tool-review-note p-4">
<p class="text-uppercase small fw-semibold text-secondary mb-2">Governanca do acesso</p>
<ul class="small text-secondary ps-3 mb-0">
{governance_notes_markup}
</ul>
</div>
</div>
</div>
</aside>
<section class="col-12 col-xl-8 col-xxl-9">
<div class="card border-0 shadow-sm admin-hero-card overflow-hidden mb-4">
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="badge rounded-pill bg-white text-dark border">Gestao de equipe</span>
<span class="badge rounded-pill bg-dark-subtle text-dark-emphasis border border-dark-subtle">Sessao protegida</span>
</div>
<h2 class="display-5 fw-semibold mb-3">Cadastro e status da equipe administrativa</h2>
<p class="lead text-secondary mb-0">
Esta area centraliza o onboarding de colaboradores e deixa o diretor com uma leitura simples de quem esta ativo no painel.
</p>
</div>
<div class="admin-collaborator-kpi rounded-4 p-3">
<p class="small text-uppercase fw-semibold text-secondary mb-2">Politica de senha</p>
<div class="fw-semibold">{escape(view.password_policy_label)}</div>
</div>
</div>
</div>
</div>
<div class="alert d-none rounded-4 mb-4" id="admin-collaborator-feedback" role="status"></div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm admin-metric-card h-100">
<div class="card-body p-4">
<p class="small text-uppercase fw-semibold text-secondary mb-3">Total de colaboradores</p>
<div class="display-6 fw-semibold mb-2" data-collaborator-total>0</div>
<p class="text-secondary mb-0">Contas administrativas de colaborador cadastradas no painel.</p>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm admin-metric-card h-100">
<div class="card-body p-4">
<p class="small text-uppercase fw-semibold text-secondary mb-3">Ativos</p>
<div class="display-6 fw-semibold mb-2" data-collaborator-active-count>0</div>
<p class="text-secondary mb-0">Colaboradores que podem entrar normalmente no admin.</p>
</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="card border-0 shadow-sm admin-metric-card h-100">
<div class="card-body p-4">
<p class="small text-uppercase fw-semibold text-secondary mb-3">Inativos</p>
<div class="display-6 fw-semibold mb-2" data-collaborator-inactive-count>0</div>
<p class="text-secondary mb-0">Acessos pausados sem remover rastreabilidade da conta.</p>
</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xxl-5">
<div id="collaborator-form-card" class="card border-0 shadow-sm admin-surface-card h-100">
<div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Novo acesso</p>
<h3 class="h3 fw-semibold mb-2">Cadastrar colaborador</h3>
<p class="text-secondary mb-0">Crie a conta inicial da equipe com nome, email e senha provisoria ja validada pela politica do admin.</p>
</div>
</div>
<form class="vstack gap-4" data-admin-collaborator-form="true">
<div class="row g-3">
<div class="col-12">
<label class="form-label fw-semibold" for="collaborator-display-name">Nome do colaborador</label>
<input class="form-control form-control-lg rounded-4 admin-tool-form-control" id="collaborator-display-name" name="display_name" type="text" placeholder="Nome da pessoa" required>
</div>
<div class="col-12">
<label class="form-label fw-semibold" for="collaborator-email">Email interno</label>
<input class="form-control form-control-lg rounded-4 admin-tool-form-control" id="collaborator-email" name="email" type="email" placeholder="colaborador@empresa.com" autocomplete="email" required>
</div>
<div class="col-12">
<label class="form-label fw-semibold" for="collaborator-password">Senha inicial</label>
<input class="form-control form-control-lg rounded-4 admin-tool-form-control" id="collaborator-password" name="password" type="password" placeholder="Senha inicial do colaborador" autocomplete="new-password" required>
</div>
<div class="col-12">
<div class="admin-collaborator-kpi rounded-4 p-3 small text-secondary">
<strong>Politica atual:</strong> {escape(view.password_policy_label)}
</div>
</div>
<div class="col-12">
<div class="form-check form-switch pt-1">
<input class="form-check-input" type="checkbox" role="switch" id="collaborator-active" name="is_active" checked>
<label class="form-check-label text-secondary" for="collaborator-active">Criar conta ja ativa</label>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-3">
<button class="btn btn-dark btn-lg rounded-pill px-4 d-inline-flex align-items-center gap-2" type="submit">
<span data-collaborator-submit-label>Criar colaborador</span>
<span class="spinner-border spinner-border-sm d-none" data-collaborator-submit-spinner aria-hidden="true"></span>
</button>
<button class="btn btn-outline-secondary btn-lg rounded-pill px-4" type="reset">Limpar</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-12 col-xxl-7">
<div class="card border-0 shadow-sm admin-surface-card h-100">
<div class="card-body p-4 p-lg-5 d-flex flex-column gap-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<p class="text-uppercase small fw-semibold text-secondary mb-2">Equipe cadastrada</p>
<h3 class="h3 fw-semibold mb-2">Leitura atual da equipe interna</h3>
<p class="text-secondary mb-0">A lista abaixo vem da superficie web do painel e permite ligar ou desligar acessos rapidamente.</p>
</div>
<button class="btn btn-outline-dark rounded-pill px-4" type="button" data-admin-collaborator-refresh>
<span data-collaborator-refresh-label>Atualizar lista</span>
<span class="spinner-border spinner-border-sm d-none" data-collaborator-refresh-spinner aria-hidden="true"></span>
</button>
</div>
<div class="admin-collaborator-grid" data-collaborator-list>
<div class="admin-tool-empty-state rounded-4 p-4">
<h4 class="h5 fw-semibold mb-2">Nenhum colaborador carregado ainda</h4>
<p class="text-secondary mb-0">Clique em atualizar lista para sincronizar o estado atual da equipe.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<script src="{BOOTSTRAP_JS_HREF}" defer></script>
<script src="{escape(js_href, quote=True)}" defer></script>
</body>
</html>
'''
def _render_collaborator_cards(items: list[dict]) -> str:
return "\n".join(
f'''<article class="admin-collaborator-card rounded-4 p-4"><div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="small text-uppercase fw-semibold text-secondary mb-2">{escape(str(item.get("role") or "colaborador"))}</div><h4 class="h5 fw-semibold mb-1">{escape(str(item.get("display_name") or "Colaborador"))}</h4><div class="small text-secondary">{escape(str(item.get("email") or ""))}</div></div><span class="badge rounded-pill {'bg-success-subtle text-success-emphasis border border-success-subtle' if item.get("is_active") else 'bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle'}">{'Ativo' if item.get("is_active") else 'Inativo'}</span></div><div class="small text-secondary admin-collaborator-meta mb-3"><div><strong>Ultimo login:</strong> {escape(str(item.get("last_login_at") or "Ainda nao acessou"))}</div><div><strong>ID:</strong> {escape(str(item.get("id") or "-"))}</div></div><button class="btn btn-sm {'btn-outline-secondary' if item.get("is_active") else 'btn-outline-dark'} rounded-pill px-3" type="button" data-collaborator-toggle="true" data-collaborator-id="{escape(str(item.get("id") or ""), quote=True)}" data-collaborator-next-state="{'false' if item.get("is_active") else 'true'}">{'Desativar acesso' if item.get("is_active") else 'Reativar acesso'}</button></article>'''
for item in items
)

@ -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)

@ -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 = `
<article class="admin-tool-preview-card rounded-4 p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<div class="small text-uppercase fw-semibold text-secondary mb-2">${escapeHtml(draft?.domain || "tool")}</div>
<h4 class="h4 fw-semibold mb-1">${escapeHtml(draft?.display_name || "Nova tool")}</h4>
<div class="small text-secondary">${escapeHtml(draft?.tool_name || "")}</div>
</div>
<span class="badge rounded-pill bg-info-subtle text-info-emphasis border border-info-subtle">${escapeHtml(draft?.status || "draft")}</span>
</div>
<p class="text-secondary mb-3">${escapeHtml(draft?.summary || "")}</p>
<div class="admin-tool-preview-meta small text-secondary mb-3">
<div><strong>Objetivo:</strong> ${escapeHtml(draft?.business_goal || "")}</div>
<div><strong>Parametros:</strong> ${escapeHtml(String(draft?.parameter_count || 0))}</div>
<div><strong>Obrigatorios:</strong> ${escapeHtml(String(draft?.required_parameter_count || 0))}</div>
<div><strong>Aprovacao:</strong> ${draft?.requires_director_approval ? "Diretor obrigatorio" : "Nao"}</div>
</div>
<div class="vstack gap-2 mb-3">
${parameters.length > 0
? parameters.map((item) => `<div class="admin-tool-inline-note rounded-4 p-3"><div class="d-flex justify-content-between gap-3"><div><div class="fw-semibold">${escapeHtml(item.name)}</div><div class="small text-secondary mt-1">${escapeHtml(item.description)}</div></div><span class="badge rounded-pill bg-body-tertiary text-secondary border">${escapeHtml(item.parameter_type)}</span></div></div>`).join("")
: `<div class="admin-tool-inline-note rounded-4 p-3"><div class="fw-semibold">Sem parametros</div><div class="small text-secondary mt-1">A tool foi cadastrada sem parametros de entrada.</div></div>`}
</div>
<div class="admin-tool-preview-stack vstack gap-3">
<div>
<div class="small text-uppercase fw-semibold text-secondary mb-2">Avisos</div>
${warnings.length > 0
? `<ul class="small text-secondary ps-3 mb-0">${warnings.map((item) => `<li class="mb-2">${escapeHtml(item)}</li>`).join("")}</ul>`
: `<div class="small text-secondary">Nenhum aviso extra para este pre-cadastro.</div>`}
</div>
<div>
<div class="small text-uppercase fw-semibold text-secondary mb-2">Proximos passos</div>
${nextSteps.length > 0
? `<ul class="small text-secondary ps-3 mb-0">${nextSteps.map((item) => `<li class="mb-2">${escapeHtml(item)}</li>`).join("")}</ul>`
: `<div class="small text-secondary">Sem orientacoes adicionais.</div>`}
</div>
</div>
</article>
`;
}
}
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 = `<div class="admin-tool-empty-state rounded-4 p-4"><h4 class="h5 fw-semibold mb-2">Leitura indisponivel</h4><p class="text-secondary mb-0">${escapeHtml(result.message || "Nao foi possivel carregar os colaboradores.")}</p></div>`;
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 `<article class="admin-collaborator-card rounded-4 p-4"><div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="small text-uppercase fw-semibold text-secondary mb-2">${escapeHtml(item?.role || "colaborador")}</div><h4 class="h5 fw-semibold mb-1">${escapeHtml(item?.display_name || "Colaborador")}</h4><div class="small text-secondary">${escapeHtml(item?.email || "")}</div></div><span class="badge rounded-pill ${statusBadge}">${item?.is_active ? "Ativo" : "Inativo"}</span></div><div class="small text-secondary admin-collaborator-meta mb-3"><div><strong>Ultimo login:</strong> ${escapeHtml(lastLogin)}</div><div><strong>ID:</strong> ${escapeHtml(String(item?.id || "-"))}</div></div><button class="btn btn-sm ${buttonClass} rounded-pill px-3" type="button" data-collaborator-toggle="true" data-collaborator-id="${escapeHtml(String(item?.id || ""))}" data-collaborator-next-state="${item?.is_active ? "false" : "true"}">${actionLabel}</button></article>`;
}).join("")
: `<div class="admin-tool-empty-state rounded-4 p-4"><h4 class="h5 fw-semibold mb-2">Nenhum colaborador cadastrado ainda</h4><p class="text-secondary mb-0">Use o formulario ao lado para criar o primeiro colaborador administrativo.</p></div>`;
}
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('"', "&quot;")
.replaceAll("'", "&#39;");
}
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",
});
}

@ -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;
}

@ -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, ...]

@ -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

@ -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

@ -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

@ -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:

@ -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()

@ -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()

@ -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()
unittest.main()

@ -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()
unittest.main()

@ -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()

@ -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__":

@ -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()

@ -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()

@ -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:

@ -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()

@ -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()

@ -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:

@ -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()

@ -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)

Loading…
Cancel
Save