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