نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""FastAPI dependencies: authentication, tenant resolution and capability checks.
|
||||
|
||||
The single most important rule of this file: **every** organization-scoped
|
||||
endpoint resolves its tenant through ``get_org_context``, which refuses to
|
||||
return anything unless the caller has an active membership. There is no
|
||||
"trust the org_id in the path" path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import Depends, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, NotFoundError, PermissionDeniedError
|
||||
from app.core.security import decode_access_token
|
||||
from app.domain.identity.models import Membership, Organization, Role, User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.request_context import note_actor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.application.services.project_service import ProjectService
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.application.services.workforce_service import WorkforceService
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrgContext:
|
||||
"""Resolved tenant context for an organization-scoped request."""
|
||||
|
||||
organization: Organization
|
||||
membership: Membership
|
||||
user: User
|
||||
|
||||
@property
|
||||
def org_id(self) -> str:
|
||||
return self.organization.id
|
||||
|
||||
def require(self, capability: str) -> None:
|
||||
if not self.membership.has(capability):
|
||||
raise PermissionDeniedError("شما دسترسی لازم برای این عملیات را ندارید.")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User:
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthenticationError("برای این عملیات باید وارد شوید.")
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise AuthenticationError("توکن نامعتبر است.")
|
||||
user = await UserRepository(session).get(str(user_id))
|
||||
if user is None or not user.is_active:
|
||||
raise AuthenticationError("حساب کاربری یافت نشد یا غیرفعال است.")
|
||||
# Record the verified actor so any later failure is attributable. Uses the
|
||||
# id from the database, not the one in the token.
|
||||
note_actor(request, user_id=user.id)
|
||||
return user
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User | None:
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
try:
|
||||
return await get_current_user(request, credentials, session)
|
||||
except AuthenticationError:
|
||||
return None
|
||||
|
||||
|
||||
async def require_platform_owner(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_platform_owner:
|
||||
raise PermissionDeniedError("این بخش فقط برای مالک سامانه قابل دسترسی است.")
|
||||
return user
|
||||
|
||||
|
||||
async def get_org_context(
|
||||
request: Request,
|
||||
org_id: str,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrgContext:
|
||||
"""Resolve ``org_id`` into an authorized tenant context.
|
||||
|
||||
Returns 404 (not 403) when the caller has no membership, so the API does not
|
||||
leak the existence of other organizations.
|
||||
"""
|
||||
organization = await OrganizationRepository(session).get(org_id)
|
||||
if organization is None:
|
||||
raise NotFoundError("سازمان یافت نشد.")
|
||||
membership = await MembershipRepository(session).get(org_id, user.id)
|
||||
if membership is None or not membership.is_active:
|
||||
raise NotFoundError("سازمان یافت نشد.")
|
||||
# The tenant is only known *after* the membership check passes, so this is
|
||||
# the earliest point at which it is safe to attribute a failure to an org.
|
||||
note_actor(request, organization_id=organization.id, user_id=user.id)
|
||||
return OrgContext(organization=organization, membership=membership, user=user)
|
||||
|
||||
|
||||
def require_capability(capability: str):
|
||||
"""Dependency factory: ``Depends(require_capability(CAN_MANAGE_PROJECTS))``."""
|
||||
|
||||
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
|
||||
context.require(capability)
|
||||
return context
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
def require_role_at_least(minimum: Role):
|
||||
from app.domain.identity.models import ROLE_RANK
|
||||
|
||||
async def _checker(context: OrgContext = Depends(get_org_context)) -> OrgContext:
|
||||
if ROLE_RANK[context.membership.role] < ROLE_RANK[minimum]:
|
||||
raise PermissionDeniedError("سطح دسترسی شما برای این عملیات کافی نیست.")
|
||||
return context
|
||||
|
||||
return _checker
|
||||
|
||||
|
||||
def client_meta(request: Request) -> tuple[str, str]:
|
||||
"""``(ip, user_agent)`` for the audit trail."""
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
ip = (
|
||||
forwarded.split(",")[0].strip()
|
||||
if forwarded
|
||||
else (request.client.host if request.client else "")
|
||||
)
|
||||
return ip, request.headers.get("user-agent", "")[:255]
|
||||
|
||||
|
||||
# The builders themselves live in ``app.application.container`` so that the
|
||||
# standalone workers compose services exactly the way the API does. These
|
||||
# functions are thin FastAPI dependency wrappers around them.
|
||||
|
||||
|
||||
async def get_project_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProjectService:
|
||||
"""Build the project/domain service with the configured adapters."""
|
||||
from app.application.container import build_project_service
|
||||
|
||||
return build_project_service(session)
|
||||
|
||||
|
||||
async def get_workforce_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkforceService:
|
||||
"""Build the workforce/chat/run service with the configured adapters."""
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
return build_workforce_service(session)
|
||||
|
||||
|
||||
async def get_billing_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> BillingService:
|
||||
"""Build the billing service with the configured payment adapter."""
|
||||
from app.application.container import build_billing_service
|
||||
|
||||
return build_billing_service(session)
|
||||
|
||||
|
||||
async def get_knowledge_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> KnowledgeService:
|
||||
"""Build the knowledge service with the configured vector adapter."""
|
||||
from app.application.container import build_knowledge_service
|
||||
|
||||
return build_knowledge_service(session)
|
||||
|
||||
|
||||
async def get_sandbox_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> SandboxService:
|
||||
"""Build the sandbox gateway with the configured execution adapter."""
|
||||
from app.application.container import build_sandbox_service
|
||||
|
||||
return build_sandbox_service(session)
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Translate domain errors into HTTP responses — and record them.
|
||||
|
||||
The inner layers never import FastAPI; this module is the only place that knows
|
||||
about status codes. It is also the single choke point through which every
|
||||
request-scoped failure passes, which makes it the right place to persist them.
|
||||
|
||||
What gets recorded, and why:
|
||||
|
||||
* **Unhandled exceptions and 5xx ``AppError``s** — always. A provider being down
|
||||
or a quota check failing is an operational fault someone must see.
|
||||
* **A bare ``ValueError`` escaping to the handler** — always, at ``warning``.
|
||||
The codebase has a typed ``ValidationError`` for expected bad input and uses
|
||||
it 30 times, so a bare ``ValueError`` reaching here is usually a typing gap
|
||||
worth reviewing. The set of call sites is small and bounded.
|
||||
* **Other 4xx** — only when ``ERROR_LOG_CLIENT_ERRORS`` is on. Most 4xx are a
|
||||
caller sending bad input, and a public API receives a steady stream of 404s
|
||||
from scanners; recording all of them by default would bury the real bugs.
|
||||
Recorded at ``warning`` so they never look like faults.
|
||||
|
||||
Every error body carries the request id, and every response carries it in the
|
||||
``X-Request-ID`` header, so a user can quote one string and land on exactly one
|
||||
row in the console.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.domain.audit.models import ErrorSeverity, ErrorSource
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.interfaces.http.request_context import REQUEST_ID_HEADER, context_of
|
||||
|
||||
logger = logging.getLogger("agy.api")
|
||||
|
||||
#: Statuses at or above this are "the server is broken".
|
||||
SERVER_ERROR_THRESHOLD = 500
|
||||
|
||||
#: Cap on the per-field validation detail echoed back and stored. A caller
|
||||
#: sending a 10 000-element array should not produce a 10 000-line error row.
|
||||
MAX_VALIDATION_ERRORS = 20
|
||||
|
||||
|
||||
def _request_id(request: Request) -> str:
|
||||
context = context_of(request)
|
||||
return context.request_id if context else ""
|
||||
|
||||
|
||||
def _error_response(
|
||||
*,
|
||||
status_code: int,
|
||||
code: str,
|
||||
message: str,
|
||||
request_id: str,
|
||||
details: object = None,
|
||||
) -> JSONResponse:
|
||||
"""The one place an error response is built.
|
||||
|
||||
The request id goes in the header as well as the body, and it has to be set
|
||||
*here* rather than by the middleware: an unhandled exception is turned into a
|
||||
response by Starlette's ``ServerErrorMiddleware``, which sits outside every
|
||||
user middleware, so the middleware never sees the response to add a header
|
||||
to. The body alone would leave an HTTP client that only reads headers with
|
||||
no way to correlate the failure.
|
||||
"""
|
||||
error: dict = {"code": code, "message": message, "request_id": request_id}
|
||||
if details is not None:
|
||||
error["details"] = details
|
||||
headers = {REQUEST_ID_HEADER: request_id} if request_id else None
|
||||
return JSONResponse(
|
||||
status_code=status_code, content={"error": error}, headers=headers
|
||||
)
|
||||
|
||||
|
||||
async def _record(
|
||||
request: Request,
|
||||
*,
|
||||
kind: str,
|
||||
message: str,
|
||||
status_code: int | None,
|
||||
exc: BaseException | None = None,
|
||||
severity: str = ErrorSeverity.ERROR,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
"""Persist a request-scoped failure. Never raises."""
|
||||
context = context_of(request)
|
||||
try:
|
||||
await record_error(
|
||||
kind=kind,
|
||||
message=message,
|
||||
exc=exc,
|
||||
source=ErrorSource.HTTP,
|
||||
severity=severity,
|
||||
status_code=status_code,
|
||||
method=context.method if context else request.method,
|
||||
path=context.path if context else request.url.path,
|
||||
organization_id=context.organization_id if context else None,
|
||||
user_id=context.user_id if context else None,
|
||||
run_id=context.run_id if context else None,
|
||||
request_id=context.request_id if context else "",
|
||||
ip=context.ip if context else "",
|
||||
user_agent=context.user_agent if context else "",
|
||||
metadata=metadata,
|
||||
)
|
||||
except Exception: # pragma: no cover - the recorder is already total
|
||||
logger.exception("error-log: unexpected failure while recording an error")
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(AppError)
|
||||
async def _app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
is_server_fault = exc.status_code >= SERVER_ERROR_THRESHOLD
|
||||
if settings.error_log_enabled and (is_server_fault or settings.error_log_client_errors):
|
||||
await _record(
|
||||
request,
|
||||
kind=exc.code,
|
||||
message=exc.message,
|
||||
status_code=exc.status_code,
|
||||
# Pass the exception so the stack is captured: a
|
||||
# ProviderUnavailable raised three layers down is far easier to
|
||||
# fix with the traceback than with the message alone.
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.ERROR if is_server_fault else ErrorSeverity.WARNING,
|
||||
metadata={"details": exc.details} if exc.details is not None else None,
|
||||
)
|
||||
|
||||
return _error_response(
|
||||
status_code=exc.status_code,
|
||||
code=exc.code,
|
||||
message=exc.message,
|
||||
request_id=_request_id(request),
|
||||
details=exc.details,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_error_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Reject malformed input in the standard envelope.
|
||||
|
||||
FastAPI's default body is ``{"detail": [...]}``, which the web client
|
||||
cannot read — it would surface every validation failure as a generic
|
||||
"server error (422)". Same status, useful shape.
|
||||
"""
|
||||
errors = [
|
||||
{
|
||||
"loc": [str(part) for part in err.get("loc", ())],
|
||||
"msg": str(err.get("msg", "")),
|
||||
}
|
||||
for err in exc.errors()[:MAX_VALIDATION_ERRORS]
|
||||
]
|
||||
if settings.error_log_enabled and settings.error_log_client_errors:
|
||||
await _record(
|
||||
request,
|
||||
kind="request_validation_error",
|
||||
message="; ".join(f"{'.'.join(e['loc'])}: {e['msg']}" for e in errors),
|
||||
status_code=422,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=422,
|
||||
code="validation_error",
|
||||
message="دادههای ارسالی نامعتبر است.",
|
||||
request_id=_request_id(request),
|
||||
details=errors,
|
||||
)
|
||||
|
||||
@app.exception_handler(ValueError)
|
||||
async def _value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
|
||||
if settings.error_log_enabled:
|
||||
await _record(
|
||||
request,
|
||||
kind="value_error",
|
||||
message=str(exc),
|
||||
status_code=422,
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.WARNING,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=422,
|
||||
code="validation_error",
|
||||
message=str(exc),
|
||||
request_id=_request_id(request),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""The last line of defence: an unexpected failure.
|
||||
|
||||
Recorded unconditionally when logging is enabled — this is precisely the
|
||||
class of error the console exists for.
|
||||
"""
|
||||
logger.exception("Unhandled error on %s %s", request.method, request.url.path)
|
||||
if settings.error_log_enabled:
|
||||
await _record(
|
||||
request,
|
||||
kind="unhandled_exception",
|
||||
message=str(exc) or type(exc).__name__,
|
||||
status_code=500,
|
||||
exc=exc,
|
||||
severity=ErrorSeverity.CRITICAL,
|
||||
)
|
||||
return _error_response(
|
||||
status_code=500,
|
||||
code="internal_error",
|
||||
message="خطای داخلی سرور. لطفاً دوباره تلاش کنید.",
|
||||
request_id=_request_id(request),
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Per-request correlation and actor context.
|
||||
|
||||
Every request gets an id before it reaches a route. That id is returned in the
|
||||
``X-Request-ID`` response header, included in every error body, and written onto
|
||||
every error record — so a user reporting "it broke" can hand over one string
|
||||
that points at exactly one row in the error console.
|
||||
|
||||
The same object carries the resolved tenant and user. Authentication happens in
|
||||
dependencies, long after the middleware has run, so the middleware seeds the
|
||||
context with what it knows (method, path, ip, user agent) and the auth
|
||||
dependencies fill in the rest via :func:`note_actor`. Error handlers then read
|
||||
one object instead of re-deriving anything.
|
||||
|
||||
Deliberately stored on ``request.state`` rather than in a ``contextvar``: it is
|
||||
the same object the handler already has in hand, it survives every task
|
||||
boundary Starlette may introduce, and it cannot leak between requests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import Response
|
||||
|
||||
#: Header used to both accept a caller-supplied id and echo it back. Accepting
|
||||
#: an upstream id matters when the API sits behind a reverse proxy or a
|
||||
#: frontend BFF that already correlates its own logs.
|
||||
REQUEST_ID_HEADER = "X-Request-ID"
|
||||
|
||||
#: Long enough to be unique, short enough to paste into a bug report.
|
||||
REQUEST_ID_LENGTH = 16
|
||||
|
||||
_STATE_ATTR = "error_context"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RequestContext:
|
||||
"""What we know about the request an error happened on."""
|
||||
|
||||
request_id: str
|
||||
method: str
|
||||
path: str
|
||||
ip: str = ""
|
||||
user_agent: str = ""
|
||||
#: Filled in by the auth dependencies once the caller is authenticated.
|
||||
organization_id: str | None = None
|
||||
user_id: str | None = None
|
||||
#: Set when a failure happens inside a specific agent run.
|
||||
run_id: str | None = None
|
||||
#: Query string, kept separate from ``path`` so fingerprints group across
|
||||
#: different filters on the same endpoint.
|
||||
query: str = ""
|
||||
|
||||
|
||||
def new_request_id() -> str:
|
||||
return uuid.uuid4().hex[:REQUEST_ID_LENGTH]
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""Real client address, honouring one proxy hop.
|
||||
|
||||
Same rule as ``deps.client_meta``: a trusted reverse proxy sets
|
||||
``X-Forwarded-For``, and the left-most entry is the original caller.
|
||||
"""
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else ""
|
||||
|
||||
|
||||
def context_of(request: Request) -> RequestContext | None:
|
||||
"""The context for this request, or ``None`` outside the middleware."""
|
||||
return getattr(request.state, _STATE_ATTR, None)
|
||||
|
||||
|
||||
def note_actor(
|
||||
request: Request,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
organization_id: str | None = None,
|
||||
run_id: str | None = None,
|
||||
) -> None:
|
||||
"""Record who/which tenant this request belongs to.
|
||||
|
||||
Called from the auth dependencies. Silent when there is no context (a
|
||||
direct unit-test call to a dependency, for instance) — correlation is
|
||||
telemetry, and telemetry must never be the reason a request fails.
|
||||
"""
|
||||
context = context_of(request)
|
||||
if context is None:
|
||||
return
|
||||
if user_id:
|
||||
context.user_id = user_id
|
||||
if organization_id:
|
||||
context.organization_id = organization_id
|
||||
if run_id:
|
||||
context.run_id = run_id
|
||||
|
||||
|
||||
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||
"""Attach a :class:`RequestContext` to every request and echo its id."""
|
||||
|
||||
async def dispatch(
|
||||
self, request: Request, call_next: RequestResponseEndpoint
|
||||
) -> Response:
|
||||
incoming = request.headers.get(REQUEST_ID_HEADER, "").strip()
|
||||
# Cap the accepted value: an unbounded header would let a caller write
|
||||
# arbitrarily long ids into the error table.
|
||||
request_id = incoming[:64] if incoming else new_request_id()
|
||||
|
||||
setattr( # noqa: B010 - the attribute name is a module constant
|
||||
request.state,
|
||||
_STATE_ATTR,
|
||||
RequestContext(
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
query=request.url.query or "",
|
||||
ip=client_ip(request),
|
||||
user_agent=request.headers.get("user-agent", "")[:255],
|
||||
),
|
||||
)
|
||||
response = await call_next(request)
|
||||
response.headers[REQUEST_ID_HEADER] = request_id
|
||||
return response
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REQUEST_ID_HEADER",
|
||||
"RequestContext",
|
||||
"RequestContextMiddleware",
|
||||
"client_ip",
|
||||
"context_of",
|
||||
"new_request_id",
|
||||
"note_actor",
|
||||
]
|
||||
@@ -0,0 +1,715 @@
|
||||
"""Platform owner console: providers, models, routing policies and rate cards.
|
||||
|
||||
Everything in this router is invisible to customers. Two rules are enforced
|
||||
here and tested:
|
||||
|
||||
1. Only a platform owner may call any of it.
|
||||
2. A provider API key is **never** returned — not masked-and-then-leaked, not in
|
||||
a nested object. Only ``masked_key`` leaves this module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.crypto import encrypt, mask
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.domain.ai_control.models import (
|
||||
AiModel,
|
||||
Capability,
|
||||
ModelCredential,
|
||||
Provider,
|
||||
ProviderType,
|
||||
RateCard,
|
||||
RateUnit,
|
||||
RoutingMode,
|
||||
RoutingPolicy,
|
||||
)
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import User
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
ProviderRepository,
|
||||
RateCardRepository,
|
||||
RoutingPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import (
|
||||
ObservabilityRepository,
|
||||
UsageLedgerRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin"],
|
||||
# Deny-by-default for the whole console. This is a router-level dependency on
|
||||
# purpose: an endpoint added later is protected without its author having to
|
||||
# remember. Relying on per-endpoint checks alone already caused a real
|
||||
# privilege-escalation bug — every GET here once shipped without the check,
|
||||
# letting any authenticated customer list all providers and every
|
||||
# organization on the platform.
|
||||
#
|
||||
# Endpoints that also declare `owner: User = Depends(require_platform_owner)`
|
||||
# do so because they *use* the object (for the audit actor); FastAPI caches
|
||||
# the dependency within a request, so it is not evaluated twice.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProviderOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
base_url: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateProviderRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
type: ProviderType = ProviderType.MOCK
|
||||
base_url: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class CredentialOut(BaseModel):
|
||||
"""Deliberately has no field capable of holding the plaintext key."""
|
||||
|
||||
id: str
|
||||
provider_id: str
|
||||
name: str
|
||||
masked_key: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateCredentialRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
api_key: str = Field(min_length=4, max_length=512)
|
||||
|
||||
|
||||
class ModelOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
provider_id: str
|
||||
internal_name: str
|
||||
display_name_for_admin: str
|
||||
capabilities: list[str]
|
||||
input_price_per_1m: float
|
||||
output_price_per_1m: float
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateModelRequest(BaseModel):
|
||||
provider_id: str
|
||||
internal_name: str = Field(min_length=1, max_length=160)
|
||||
display_name_for_admin: str = Field(min_length=1, max_length=160)
|
||||
capabilities: list[Capability] = Field(default_factory=list)
|
||||
input_price_per_1m: float = Field(default=0.0, ge=0)
|
||||
output_price_per_1m: float = Field(default=0.0, ge=0)
|
||||
|
||||
|
||||
class RoutingPolicyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
mode: str
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
|
||||
|
||||
class CreateRoutingPolicyRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
mode: RoutingMode = RoutingMode.BALANCED
|
||||
description: str = Field(default="", max_length=500)
|
||||
is_default: bool = False
|
||||
budget_limits_json: dict = Field(default_factory=dict)
|
||||
fallback_policy_json: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RateCardOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
unit: str
|
||||
customer_price: float
|
||||
internal_cost_estimate: float
|
||||
is_active: bool
|
||||
|
||||
|
||||
class UpsertRateCardRequest(BaseModel):
|
||||
unit: RateUnit
|
||||
name: str = Field(default="", max_length=120)
|
||||
customer_price: float = Field(ge=0)
|
||||
internal_cost_estimate: float = Field(default=0.0, ge=0)
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class AdminOrganizationOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AdminUsageRow(BaseModel):
|
||||
organization_id: str
|
||||
quantity: float
|
||||
total_price: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Providers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/providers", response_model=list[ProviderOut])
|
||||
async def list_providers(session: AsyncSession = Depends(get_session)) -> list[ProviderOut]:
|
||||
providers = await ProviderRepository(session).list()
|
||||
return [
|
||||
ProviderOut(
|
||||
id=p.id, name=p.name, type=p.type.value, base_url=p.base_url,
|
||||
is_active=p.is_active, created_at=p.created_at,
|
||||
)
|
||||
for p in providers
|
||||
]
|
||||
|
||||
|
||||
@router.post("/providers", response_model=ProviderOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_provider(
|
||||
payload: CreateProviderRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProviderOut:
|
||||
repo = ProviderRepository(session)
|
||||
if await repo.get_by_name(payload.name) is not None:
|
||||
raise ConflictError("ارائهدهندهای با این نام وجود دارد.")
|
||||
provider = await repo.create(
|
||||
Provider(name=payload.name, type=payload.type, base_url=payload.base_url)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||
target_type="provider", target_id=provider.id,
|
||||
metadata={"name": provider.name, "type": provider.type.value},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ProviderOut(
|
||||
id=provider.id, name=provider.name, type=provider.type.value,
|
||||
base_url=provider.base_url, is_active=provider.is_active,
|
||||
created_at=provider.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/providers/{provider_id}/credentials",
|
||||
response_model=CredentialOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_credential(
|
||||
provider_id: str,
|
||||
payload: CreateCredentialRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CredentialOut:
|
||||
provider = await ProviderRepository(session).get(provider_id)
|
||||
if provider is None:
|
||||
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||
|
||||
credential = await ModelCredentialRepository(session).create(
|
||||
ModelCredential(
|
||||
provider_id=provider_id,
|
||||
name=payload.name,
|
||||
# Encrypted at rest; the plaintext exists only inside this request.
|
||||
encrypted_api_key=encrypt(payload.api_key),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROVIDER_CHANGE, actor_user_id=owner.id,
|
||||
target_type="credential", target_id=credential.id,
|
||||
metadata={"provider_id": provider_id, "name": credential.name},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return CredentialOut(
|
||||
id=credential.id, provider_id=credential.provider_id, name=credential.name,
|
||||
masked_key=mask(payload.api_key), is_active=credential.is_active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers/{provider_id}/credentials", response_model=list[CredentialOut])
|
||||
async def list_credentials(
|
||||
provider_id: str, session: AsyncSession = Depends(get_session)
|
||||
) -> list[CredentialOut]:
|
||||
credentials = await ModelCredentialRepository(session).list_for_provider(provider_id)
|
||||
return [
|
||||
CredentialOut(
|
||||
id=c.id, provider_id=c.provider_id, name=c.name,
|
||||
# We cannot recover the plaintext to mask it — and we must not try.
|
||||
masked_key="••••••",
|
||||
is_active=c.is_active,
|
||||
)
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/models", response_model=list[ModelOut])
|
||||
async def list_models(
|
||||
provider_id: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ModelOut]:
|
||||
models = await AiModelRepository(session).list(provider_id=provider_id)
|
||||
return [
|
||||
ModelOut(
|
||||
id=m.id, provider_id=m.provider_id, internal_name=m.internal_name,
|
||||
display_name_for_admin=m.display_name_for_admin,
|
||||
capabilities=list(m.capabilities),
|
||||
input_price_per_1m=m.input_price_per_1m,
|
||||
output_price_per_1m=m.output_price_per_1m, is_active=m.is_active,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_model(
|
||||
payload: CreateModelRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelOut:
|
||||
if await ProviderRepository(session).get(payload.provider_id) is None:
|
||||
raise NotFoundError("ارائهدهنده یافت نشد.")
|
||||
|
||||
model = await AiModelRepository(session).create(
|
||||
AiModel(
|
||||
provider_id=payload.provider_id,
|
||||
internal_name=payload.internal_name,
|
||||
display_name_for_admin=payload.display_name_for_admin,
|
||||
capabilities=[str(c) for c in payload.capabilities],
|
||||
input_price_per_1m=payload.input_price_per_1m,
|
||||
output_price_per_1m=payload.output_price_per_1m,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.MODEL_CHANGE, actor_user_id=owner.id,
|
||||
target_type="model", target_id=model.id,
|
||||
metadata={"internal_name": model.internal_name},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ModelOut(
|
||||
id=model.id, provider_id=model.provider_id, internal_name=model.internal_name,
|
||||
display_name_for_admin=model.display_name_for_admin,
|
||||
capabilities=list(model.capabilities),
|
||||
input_price_per_1m=model.input_price_per_1m,
|
||||
output_price_per_1m=model.output_price_per_1m, is_active=model.is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/routing-policies", response_model=list[RoutingPolicyOut])
|
||||
async def list_routing_policies(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[RoutingPolicyOut]:
|
||||
policies = await RoutingPolicyRepository(session).list()
|
||||
return [
|
||||
RoutingPolicyOut(
|
||||
id=p.id, name=p.name, description=p.description, mode=p.mode.value,
|
||||
is_default=p.is_default, is_active=p.is_active,
|
||||
)
|
||||
for p in policies
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/routing-policies", response_model=RoutingPolicyOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_routing_policy(
|
||||
payload: CreateRoutingPolicyRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RoutingPolicyOut:
|
||||
policy = await RoutingPolicyRepository(session).create(
|
||||
RoutingPolicy(
|
||||
name=payload.name, mode=payload.mode, description=payload.description,
|
||||
is_default=payload.is_default,
|
||||
budget_limits_json=payload.budget_limits_json,
|
||||
fallback_policy_json=payload.fallback_policy_json,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.ROUTING_CHANGE, actor_user_id=owner.id,
|
||||
target_type="routing_policy", target_id=policy.id,
|
||||
metadata={"name": policy.name, "mode": policy.mode.value},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RoutingPolicyOut(
|
||||
id=policy.id, name=policy.name, description=policy.description,
|
||||
mode=policy.mode.value, is_default=policy.is_default, is_active=policy.is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate cards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/rate-cards", response_model=list[RateCardOut])
|
||||
async def list_rate_cards(session: AsyncSession = Depends(get_session)) -> list[RateCardOut]:
|
||||
cards = await RateCardRepository(session).list()
|
||||
return [
|
||||
RateCardOut(
|
||||
id=c.id, name=c.name, unit=c.unit.value, customer_price=c.customer_price,
|
||||
internal_cost_estimate=c.internal_cost_estimate, is_active=c.is_active,
|
||||
)
|
||||
for c in cards
|
||||
]
|
||||
|
||||
|
||||
@router.put("/rate-cards", response_model=RateCardOut)
|
||||
async def upsert_rate_card(
|
||||
payload: UpsertRateCardRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RateCardOut:
|
||||
card = await RateCardRepository(session).upsert(
|
||||
RateCard(
|
||||
name=payload.name or payload.unit.value.replace("_", " "),
|
||||
unit=payload.unit,
|
||||
customer_price=payload.customer_price,
|
||||
internal_cost_estimate=payload.internal_cost_estimate,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_CREDIT, actor_user_id=owner.id,
|
||||
target_type="rate_card", target_id=card.id,
|
||||
metadata={"unit": card.unit.value, "customer_price": card.customer_price},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RateCardOut(
|
||||
id=card.id, name=card.name, unit=card.unit.value,
|
||||
customer_price=card.customer_price,
|
||||
internal_cost_estimate=card.internal_cost_estimate, is_active=card.is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-organization visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations", response_model=list[AdminOrganizationOut])
|
||||
async def list_all_organizations(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminOrganizationOut]:
|
||||
organizations = await OrganizationRepository(session).list_all()
|
||||
memberships = MembershipRepository(session)
|
||||
out: list[AdminOrganizationOut] = []
|
||||
for org in organizations:
|
||||
members = await memberships.list_for_org(org.id)
|
||||
out.append(
|
||||
AdminOrganizationOut(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
member_count=len(members), created_at=org.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/usage", response_model=list[AdminUsageRow])
|
||||
async def platform_usage(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AdminUsageRow]:
|
||||
rows = await UsageLedgerRepository(session).platform_totals()
|
||||
return [
|
||||
AdminUsageRow(
|
||||
organization_id=org_id, quantity=quantity, total_price=total_price
|
||||
)
|
||||
for org_id, quantity, total_price in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/validate-model-choice")
|
||||
async def validate_model_choice(
|
||||
mode: RoutingMode,
|
||||
_: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Dry-run of routing: which model would this mode pick, and why?"""
|
||||
model = await AiModelRepository(session).pick_for_mode(mode)
|
||||
if model is None:
|
||||
raise ValidationError("هیچ مدل فعالی برای این حالت مسیریابی وجود ندارد.")
|
||||
return {
|
||||
"mode": mode.value,
|
||||
"model_id": model.id,
|
||||
"internal_name": model.internal_name,
|
||||
"capabilities": list(model.capabilities),
|
||||
"estimated_input_price_per_1m": model.input_price_per_1m,
|
||||
"estimated_output_price_per_1m": model.output_price_per_1m,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error console
|
||||
#
|
||||
# Every failure the platform catches lands in ``error_events``. This is where a
|
||||
# platform owner reads them: grouped by fingerprint so one recurring bug is one
|
||||
# line, with the full record (stack, request id, tenant, actor) one click away.
|
||||
#
|
||||
# Resolution is *per fingerprint* for the group view — fixing a bug means the
|
||||
# whole group is dealt with — and per row for a single occurrence.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ErrorEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
message: str
|
||||
stack: str
|
||||
severity: str
|
||||
source: str
|
||||
error_type: str
|
||||
fingerprint: str
|
||||
status_code: int | None
|
||||
method: str
|
||||
path: str
|
||||
organization_id: str | None
|
||||
user_id: str | None
|
||||
run_id: str | None
|
||||
request_id: str
|
||||
ip: str
|
||||
user_agent: str
|
||||
metadata_json: dict
|
||||
resolved_at: datetime | None
|
||||
resolved_note: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ErrorGroupOut(BaseModel):
|
||||
fingerprint: str
|
||||
occurrences: int
|
||||
open_count: int
|
||||
first_seen: datetime
|
||||
last_seen: datetime
|
||||
kind: str
|
||||
error_type: str
|
||||
severity: str
|
||||
source: str
|
||||
path: str
|
||||
sample_message: str
|
||||
|
||||
|
||||
class ErrorSummaryOut(BaseModel):
|
||||
open_by_severity: dict[str, int]
|
||||
total_open: int
|
||||
|
||||
|
||||
class ErrorListOut(BaseModel):
|
||||
items: list[ErrorEventOut]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class ResolveErrorIn(BaseModel):
|
||||
note: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class ResolveErrorOut(BaseModel):
|
||||
fingerprint: str
|
||||
resolved: int
|
||||
|
||||
|
||||
#: Newest-first page size ceiling. An unbounded ``limit`` would let one request
|
||||
#: pull the entire table — including every stack trace — into memory.
|
||||
MAX_ERROR_PAGE_SIZE = 200
|
||||
|
||||
|
||||
@router.get("/errors", response_model=ErrorListOut)
|
||||
async def list_errors(
|
||||
severity: str | None = Query(default=None),
|
||||
source: str | None = Query(default=None),
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
kind: str | None = Query(default=None),
|
||||
organization_id: str | None = Query(default=None),
|
||||
fingerprint: str | None = Query(default=None),
|
||||
search: str | None = Query(default=None, max_length=200),
|
||||
since: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorListOut:
|
||||
"""Individual occurrences, newest first."""
|
||||
repo = ObservabilityRepository(session)
|
||||
filters = {
|
||||
"severity": severity,
|
||||
"source": source,
|
||||
"status": status_filter,
|
||||
"kind": kind,
|
||||
"organization_id": organization_id,
|
||||
"fingerprint": fingerprint,
|
||||
"search": search,
|
||||
"since": since,
|
||||
}
|
||||
items = await repo.list_errors(limit=limit, offset=offset, **filters)
|
||||
total = await repo.count_errors(**filters)
|
||||
return ErrorListOut(
|
||||
items=[ErrorEventOut.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/errors/summary", response_model=ErrorSummaryOut)
|
||||
async def error_summary(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorSummaryOut:
|
||||
"""Unresolved counts, for the console's header tiles."""
|
||||
by_severity = await ObservabilityRepository(session).count_open_by_severity()
|
||||
return ErrorSummaryOut(
|
||||
open_by_severity=by_severity, total_open=sum(by_severity.values())
|
||||
)
|
||||
|
||||
|
||||
# Declared before ``/errors/{error_id}`` on purpose: FastAPI matches routes in
|
||||
# declaration order, so the other way round "groups" would be captured as an id.
|
||||
@router.get("/errors/groups", response_model=list[ErrorGroupOut])
|
||||
async def list_error_groups(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
severity: str | None = Query(default=None),
|
||||
source: str | None = Query(default=None),
|
||||
organization_id: str | None = Query(default=None),
|
||||
since: datetime | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=MAX_ERROR_PAGE_SIZE),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ErrorGroupOut]:
|
||||
"""One row per distinct bug, most recently active first."""
|
||||
groups = await ObservabilityRepository(session).list_error_groups(
|
||||
limit=limit,
|
||||
since=since,
|
||||
status=status_filter,
|
||||
severity=severity,
|
||||
source=source,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return [ErrorGroupOut(**group) for group in groups]
|
||||
|
||||
|
||||
@router.get("/errors/{error_id}", response_model=ErrorEventOut)
|
||||
async def get_error(
|
||||
error_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ErrorEventOut:
|
||||
event = await ObservabilityRepository(session).get_error(error_id)
|
||||
if event is None:
|
||||
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||
return ErrorEventOut.model_validate(event)
|
||||
|
||||
|
||||
@router.post("/errors/{error_id}/resolve", response_model=ResolveErrorOut)
|
||||
async def resolve_error(
|
||||
error_id: str,
|
||||
payload: ResolveErrorIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Mark one occurrence as dealt with."""
|
||||
repo = ObservabilityRepository(session)
|
||||
event = await repo.resolve_error(error_id, note=payload.note, resolved_at=utcnow())
|
||||
if event is None:
|
||||
raise NotFoundError("خطای مورد نظر یافت نشد.")
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ERROR_RESOLVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="error_event",
|
||||
target_id=error_id,
|
||||
metadata={"fingerprint": event.fingerprint, "note": payload.note[:200]},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return ResolveErrorOut(fingerprint=event.fingerprint, resolved=1)
|
||||
|
||||
|
||||
@router.post("/errors/groups/{fingerprint}/resolve", response_model=ResolveErrorOut)
|
||||
async def resolve_error_group(
|
||||
fingerprint: str,
|
||||
payload: ResolveErrorIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Mark every open occurrence of one bug as dealt with."""
|
||||
repo = ObservabilityRepository(session)
|
||||
resolved = await repo.resolve_error_group(
|
||||
fingerprint, note=payload.note, resolved_at=utcnow()
|
||||
)
|
||||
if resolved == 0:
|
||||
raise NotFoundError("موردی برای بستن پیدا نشد؛ ممکن است قبلاً بسته شده باشد.")
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ERROR_RESOLVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="error_group",
|
||||
target_id=fingerprint,
|
||||
metadata={"resolved": resolved, "note": payload.note[:200]},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||
|
||||
|
||||
@router.post("/errors/groups/{fingerprint}/reopen", response_model=ResolveErrorOut)
|
||||
async def reopen_error_group(
|
||||
fingerprint: str,
|
||||
_: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ResolveErrorOut:
|
||||
"""Undo a resolution — for when the bug comes back."""
|
||||
resolved = await ObservabilityRepository(session).reopen_error_group(fingerprint)
|
||||
return ResolveErrorOut(fingerprint=fingerprint, resolved=resolved)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Authentication endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import AuthenticationError, ConflictError
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import User
|
||||
from app.infrastructure.repositories.identity import (
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import client_meta, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class SignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
name: str = Field(default="", max_length=160)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
avatar_url: str | None = None
|
||||
is_platform_owner: bool = False
|
||||
|
||||
|
||||
class OrganizationSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
role: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user: UserOut
|
||||
organizations: list[OrganizationSummary]
|
||||
|
||||
|
||||
def _to_user_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_platform_owner=user.is_platform_owner,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/signup", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def signup(
|
||||
payload: SignupRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
users = UserRepository(session)
|
||||
if await users.get_by_email(payload.email) is not None:
|
||||
raise ConflictError("این ایمیل قبلاً ثبت شده است.")
|
||||
|
||||
user = await users.create(
|
||||
User(
|
||||
email=payload.email,
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name.strip(),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.SIGNUP,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TokenResponse:
|
||||
user = await UserRepository(session).get_by_email(payload.email)
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
raise AuthenticationError("ایمیل یا رمز عبور نادرست است.")
|
||||
if not user.is_active:
|
||||
raise AuthenticationError("حساب کاربری غیرفعال است.")
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.LOGIN,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return TokenResponse(access_token=create_access_token(user.id), user=_to_user_out(user))
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""JWTs are stateless; logout is an audit marker today and a token-revocation
|
||||
hook once a denylist (Redis) is wired in."""
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.LOGOUT,
|
||||
actor_user_id=user.id,
|
||||
target_type="user",
|
||||
target_id=user.id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MeResponse:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
return MeResponse(
|
||||
user=_to_user_out(user),
|
||||
organizations=[
|
||||
OrganizationSummary(id=org.id, name=org.name, slug=org.slug, role=role.value)
|
||||
for org, role in orgs
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/memberships")
|
||||
async def my_memberships(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
memberships = MembershipRepository(session)
|
||||
out: list[dict] = []
|
||||
for org, role in orgs:
|
||||
membership = await memberships.get(org.id, user.id)
|
||||
out.append(
|
||||
{
|
||||
"organization_id": org.id,
|
||||
"organization_name": org.name,
|
||||
"role": role.value,
|
||||
"status": membership.status.value if membership else "unknown",
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Billing endpoints: summary, usage ledger, invoices, credit and checkout.
|
||||
|
||||
Reading billing requires ``billing.view``; changing it requires
|
||||
``billing.manage``. A tenant can therefore never see — or move — another
|
||||
organization's money.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.billing_service import BillingService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_BILLING, CAN_VIEW_BILLING
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_billing_service,
|
||||
get_org_context,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/billing", tags=["billing"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class UnitAggregateOut(BaseModel):
|
||||
unit: str
|
||||
quantity: float
|
||||
total_price: float
|
||||
|
||||
|
||||
class BillingSummaryOut(BaseModel):
|
||||
balance: float
|
||||
currency: str
|
||||
low_balance: bool
|
||||
low_balance_threshold: float
|
||||
total_spend: float
|
||||
by_unit: list[UnitAggregateOut]
|
||||
by_employee: dict[str, float]
|
||||
by_project: dict[str, float]
|
||||
|
||||
|
||||
class UsageEventOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
unit: str
|
||||
quantity: float
|
||||
unit_price: float
|
||||
total_price: float
|
||||
project_id: str | None
|
||||
employee_id: str | None
|
||||
run_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InvoiceOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
period_start: datetime | None
|
||||
period_end: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class PaymentOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
amount: float
|
||||
currency: str
|
||||
status: str
|
||||
provider: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AddCreditRequest(BaseModel):
|
||||
amount: float = Field(description="Positive to top up; negative is platform-owner only.")
|
||||
|
||||
|
||||
class WalletOut(BaseModel):
|
||||
balance: float
|
||||
currency: str
|
||||
low_balance: bool
|
||||
|
||||
|
||||
class CheckoutRequest(BaseModel):
|
||||
amount: float = Field(gt=0)
|
||||
success_url: str = Field(default="", max_length=512)
|
||||
cancel_url: str = Field(default="", max_length=512)
|
||||
|
||||
|
||||
class CheckoutResponse(BaseModel):
|
||||
payment_id: str
|
||||
checkout_url: str
|
||||
amount: float
|
||||
|
||||
|
||||
class SettlePaymentRequest(BaseModel):
|
||||
succeeded: bool = True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/summary", response_model=BillingSummaryOut)
|
||||
async def billing_summary(
|
||||
since: datetime | None = Query(default=None),
|
||||
until: datetime | None = Query(default=None),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> BillingSummaryOut:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
summary = await service.summary(
|
||||
organization_id=context.org_id, since=since, until=until
|
||||
)
|
||||
return BillingSummaryOut(
|
||||
balance=summary["balance"],
|
||||
currency=summary["currency"],
|
||||
low_balance=summary["low_balance"],
|
||||
low_balance_threshold=summary["low_balance_threshold"],
|
||||
total_spend=summary["total_spend"],
|
||||
by_unit=[
|
||||
UnitAggregateOut(
|
||||
unit=str(a.unit), quantity=a.quantity, total_price=a.total_price
|
||||
)
|
||||
for a in summary["by_unit"]
|
||||
],
|
||||
by_employee=summary["by_employee"],
|
||||
by_project=summary["by_project"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/usage-events", response_model=list[UsageEventOut])
|
||||
async def list_usage_events(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[UsageEventOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
rows = await service.list_usage_events(
|
||||
organization_id=context.org_id, limit=limit, offset=offset
|
||||
)
|
||||
return [
|
||||
UsageEventOut(
|
||||
id=r.id, unit=r.unit, quantity=r.quantity, unit_price=r.unit_price,
|
||||
total_price=r.total_price, project_id=r.project_id,
|
||||
employee_id=r.employee_id, run_id=r.run_id, created_at=r.created_at,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/invoices", response_model=list[InvoiceOut])
|
||||
async def list_invoices(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[InvoiceOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
invoices = await service.list_invoices(context.org_id)
|
||||
return [
|
||||
InvoiceOut(
|
||||
id=i.id, amount=i.amount, currency=i.currency, status=str(i.status),
|
||||
period_start=i.period_start, period_end=i.period_end, created_at=i.created_at,
|
||||
)
|
||||
for i in invoices
|
||||
]
|
||||
|
||||
|
||||
@router.get("/payments", response_model=list[PaymentOut])
|
||||
async def list_payments(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> list[PaymentOut]:
|
||||
context.require(CAN_VIEW_BILLING)
|
||||
wallet = await service.wallet_for(context.org_id)
|
||||
payments = await service.list_payments(context.org_id)
|
||||
return [
|
||||
PaymentOut(
|
||||
id=p.id, amount=p.amount, currency=wallet.currency, status=str(p.status),
|
||||
provider=p.provider, created_at=p.created_at,
|
||||
)
|
||||
for p in payments
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/add-credit", response_model=WalletOut)
|
||||
async def add_credit(
|
||||
payload: AddCreditRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WalletOut:
|
||||
# A negative delta is a platform-owner-only clawback.
|
||||
if payload.amount < 0:
|
||||
if not context.user.is_platform_owner:
|
||||
context.require(CAN_MANAGE_BILLING) # will raise PermissionDeniedError
|
||||
from app.core.errors import PermissionDeniedError
|
||||
|
||||
raise PermissionDeniedError("کسر اعتبار فقط توسط مالک سامانه انجام میشود.")
|
||||
else:
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
|
||||
wallet = await service.add_credit(
|
||||
organization_id=context.org_id,
|
||||
amount=payload.amount,
|
||||
allow_negative=context.user.is_platform_owner,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_CREDIT, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="credit_wallet",
|
||||
target_id=wallet.id,
|
||||
metadata={"amount": payload.amount, "balance": wallet.balance},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return WalletOut(
|
||||
balance=wallet.balance, currency=wallet.currency, low_balance=wallet.is_low
|
||||
)
|
||||
|
||||
|
||||
@router.post("/checkout", response_model=CheckoutResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def start_checkout(
|
||||
payload: CheckoutRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
) -> CheckoutResponse:
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
payment, url = await service.start_checkout(
|
||||
organization_id=context.org_id,
|
||||
amount=payload.amount,
|
||||
success_url=payload.success_url,
|
||||
cancel_url=payload.cancel_url,
|
||||
)
|
||||
return CheckoutResponse(
|
||||
payment_id=payment.id, checkout_url=url, amount=payment.amount
|
||||
)
|
||||
|
||||
|
||||
@router.post("/payments/{payment_id}/settle", response_model=PaymentOut)
|
||||
async def settle_payment(
|
||||
payment_id: str,
|
||||
payload: SettlePaymentRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: BillingService = Depends(get_billing_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PaymentOut:
|
||||
"""Finalise a payment.
|
||||
|
||||
In production the payment provider calls a webhook; this endpoint is the
|
||||
manual/dev equivalent. It is the only path that converts money into credit.
|
||||
"""
|
||||
context.require(CAN_MANAGE_BILLING)
|
||||
payment = await service.settle_payment(
|
||||
organization_id=context.org_id, payment_id=payment_id, succeeded=payload.succeeded
|
||||
)
|
||||
wallet = await service.wallet_for(context.org_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.BILLING_PAYMENT, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="payment", target_id=payment.id,
|
||||
metadata={"status": str(payment.status), "amount": payment.amount},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return PaymentOut(
|
||||
id=payment.id, amount=payment.amount, currency=wallet.currency,
|
||||
status=str(payment.status), provider=payment.provider,
|
||||
created_at=payment.created_at,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Health and readiness endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.infrastructure.adapters.registry import get_model_provider
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
"""Liveness. Must stay dependency-free so it answers during a DB outage."""
|
||||
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Readiness: verifies the database and reports which adapters are active."""
|
||||
checks: dict[str, object] = {}
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
checks["database"] = "ok"
|
||||
except Exception as exc: # noqa: BLE001 - reported, not raised
|
||||
checks["database"] = f"error: {exc.__class__.__name__}"
|
||||
|
||||
try:
|
||||
model_health = await get_model_provider().health()
|
||||
checks["model_provider"] = "ok" if model_health.ok else f"degraded: {model_health.detail}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
checks["model_provider"] = f"error: {exc.__class__.__name__}"
|
||||
|
||||
checks["adapters"] = {
|
||||
"ai": settings.ai_default_provider,
|
||||
"git": settings.git_provider,
|
||||
"domain_router": settings.domain_router,
|
||||
"sandbox": settings.sandbox_provider,
|
||||
"payment": settings.payment_provider,
|
||||
}
|
||||
healthy = checks.get("database") == "ok"
|
||||
return {"status": "ok" if healthy else "degraded", "checks": checks}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Knowledge Base endpoints.
|
||||
|
||||
Ingestion and deletion require ``employees.manage`` (the same trust level as
|
||||
editing an employee's prompt); search only requires ``tasks.run``, because
|
||||
reading your own organization's knowledge is part of doing work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.knowledge_service import KnowledgeService
|
||||
from app.core.database import get_session
|
||||
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_knowledge_service,
|
||||
get_org_context,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/knowledge", tags=["knowledge"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class KnowledgeSourceOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
kind: str
|
||||
project_id: str | None
|
||||
created_at: datetime
|
||||
chunk_count: int = 0
|
||||
|
||||
|
||||
class CreateTextSourceRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=255)
|
||||
content: str = Field(min_length=1, max_length=400_000)
|
||||
title: str = Field(default="", max_length=255)
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class DocumentOut(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmbeddingJobOut(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class KnowledgeSourceDetailOut(KnowledgeSourceOut):
|
||||
documents: list[DocumentOut]
|
||||
job: EmbeddingJobOut | None
|
||||
|
||||
|
||||
class SearchHitOut(BaseModel):
|
||||
chunk_id: str
|
||||
content: str
|
||||
index: int
|
||||
document_title: str
|
||||
source_id: str
|
||||
source_name: str
|
||||
project_id: str | None
|
||||
|
||||
|
||||
class CreateRetrievalPolicyRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
scope: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RetrievalPolicyOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
scope_json: dict
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sources", response_model=KnowledgeSourceOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_text_source(
|
||||
payload: CreateTextSourceRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> KnowledgeSourceOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
source, chunk_count = await service.create_text_source(
|
||||
organization_id=context.org_id,
|
||||
name=payload.name,
|
||||
content=payload.content,
|
||||
project_id=payload.project_id,
|
||||
title=payload.title,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="knowledge.source_create", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||
target_id=source.id, metadata={"name": source.name, "chunks": chunk_count},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return KnowledgeSourceOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=chunk_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sources", response_model=list[KnowledgeSourceOut])
|
||||
async def list_sources(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[KnowledgeSourceOut]:
|
||||
sources = await service.list_sources(context.org_id, limit=limit, offset=offset)
|
||||
# One aggregate query for every source's chunk count. Asking per source
|
||||
# inside the loop was an N+1: 100 sources meant 101 round trips.
|
||||
chunk_counts = await service.chunk_counts([s.id for s in sources])
|
||||
return [
|
||||
KnowledgeSourceOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=chunk_counts.get(source.id, 0),
|
||||
)
|
||||
for source in sources
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sources/{source_id}", response_model=KnowledgeSourceDetailOut)
|
||||
async def get_source(
|
||||
source_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> KnowledgeSourceDetailOut:
|
||||
source = await service.get_source_or_404(
|
||||
organization_id=context.org_id, source_id=source_id
|
||||
)
|
||||
detail = await service.source_detail(source_id=source.id)
|
||||
job = detail["job"]
|
||||
return KnowledgeSourceDetailOut(
|
||||
id=source.id, name=source.name, kind=str(source.kind),
|
||||
project_id=source.project_id, created_at=source.created_at,
|
||||
chunk_count=detail["chunk_count"],
|
||||
documents=[
|
||||
DocumentOut(
|
||||
id=d.id, title=d.title, content=d.content, created_at=d.created_at
|
||||
)
|
||||
for d in detail["documents"]
|
||||
],
|
||||
job=(
|
||||
EmbeddingJobOut(id=job.id, status=str(job.status), error=job.error)
|
||||
if job is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_source(
|
||||
source_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
await service.delete_source(organization_id=context.org_id, source_id=source_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="knowledge.source_delete", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="knowledge_source",
|
||||
target_id=source_id, ip=ip, user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/search", response_model=list[SearchHitOut])
|
||||
async def search_knowledge(
|
||||
q: str = Query(min_length=1, max_length=300),
|
||||
project_id: str | None = Query(default=None),
|
||||
limit: int = Query(default=5, ge=1, le=50),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> list[SearchHitOut]:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
hits = await service.search(
|
||||
organization_id=context.org_id, query=q, project_id=project_id, limit=limit
|
||||
)
|
||||
return [SearchHitOut(**hit) for hit in hits]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrieval policies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/retrieval-policies", response_model=list[RetrievalPolicyOut])
|
||||
async def list_retrieval_policies(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> list[RetrievalPolicyOut]:
|
||||
policies = await service.list_policies(context.org_id)
|
||||
return [
|
||||
RetrievalPolicyOut(id=p.id, name=p.name, scope_json=dict(p.scope_json))
|
||||
for p in policies
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/retrieval-policies",
|
||||
response_model=RetrievalPolicyOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_retrieval_policy(
|
||||
payload: CreateRetrievalPolicyRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: KnowledgeService = Depends(get_knowledge_service),
|
||||
) -> RetrievalPolicyOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
policy = await service.create_policy(
|
||||
organization_id=context.org_id, name=payload.name, scope=payload.scope
|
||||
)
|
||||
return RetrievalPolicyOut(
|
||||
id=policy.id, name=policy.name, scope_json=dict(policy.scope_json)
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
"""Organization, membership and invite endpoints.
|
||||
|
||||
Every route below the organization prefix resolves its tenant through
|
||||
``get_org_context``, so a caller can never read or mutate another tenant's data
|
||||
by guessing an id.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.security import generate_token, hash_token
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.base import ensure_unique_slug, slugify
|
||||
from app.domain.identity.models import (
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
ROLE_RANK,
|
||||
Invite,
|
||||
Membership,
|
||||
MembershipStatus,
|
||||
Organization,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from app.infrastructure.db.models import CreditWalletRow
|
||||
from app.infrastructure.repositories.audit import AuditRepository
|
||||
from app.infrastructure.repositories.identity import (
|
||||
InviteRepository,
|
||||
MembershipRepository,
|
||||
OrganizationRepository,
|
||||
UserRepository,
|
||||
)
|
||||
from app.interfaces.http.deps import OrgContext, client_meta, get_current_user, get_org_context
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
INVITE_TTL_DAYS = 14
|
||||
|
||||
|
||||
class CreateOrganizationRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
|
||||
|
||||
class OrganizationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
owner_user_id: str
|
||||
|
||||
|
||||
class OrganizationWithRole(OrganizationOut):
|
||||
role: str
|
||||
|
||||
|
||||
class MemberOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
email: str = ""
|
||||
name: str = ""
|
||||
role: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
email: EmailStr
|
||||
role: Role = Role.MEMBER
|
||||
|
||||
|
||||
class InviteOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
role: str
|
||||
expires_at: datetime
|
||||
invite_token: str
|
||||
invite_url: str
|
||||
|
||||
|
||||
class UpdateMemberRequest(BaseModel):
|
||||
role: Role | None = None
|
||||
status: MembershipStatus | None = None
|
||||
|
||||
|
||||
class AuditEventOut(BaseModel):
|
||||
id: str
|
||||
action: str
|
||||
actor_user_id: str | None
|
||||
target_type: str
|
||||
target_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
def _org_out(org: Organization) -> OrganizationOut:
|
||||
return OrganizationOut(
|
||||
id=org.id, name=org.name, slug=org.slug, owner_user_id=org.owner_user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=OrganizationWithRole, status_code=status.HTTP_201_CREATED)
|
||||
async def create_organization(
|
||||
payload: CreateOrganizationRequest,
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrganizationWithRole:
|
||||
orgs = OrganizationRepository(session)
|
||||
base = slugify(payload.name)
|
||||
slug = ensure_unique_slug(base, await orgs.list_all_slugs())
|
||||
|
||||
org = await orgs.create(
|
||||
Organization(name=payload.name.strip(), slug=slug, owner_user_id=user.id)
|
||||
)
|
||||
await MembershipRepository(session).add(
|
||||
Membership(
|
||||
organization_id=org.id,
|
||||
user_id=user.id,
|
||||
role=Role.OWNER,
|
||||
status=MembershipStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
# Every organization gets a wallet up front so billing views are never empty.
|
||||
session.add(CreditWalletRow(organization_id=org.id, balance=0.0, currency="USD"))
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.ORG_CREATE,
|
||||
organization_id=org.id,
|
||||
actor_user_id=user.id,
|
||||
target_type="organization",
|
||||
target_id=org.id,
|
||||
metadata={"name": org.name, "slug": org.slug},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
await session.flush()
|
||||
return OrganizationWithRole(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, role=Role.OWNER.value,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[OrganizationWithRole])
|
||||
async def list_organizations(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[OrganizationWithRole]:
|
||||
orgs = await OrganizationRepository(session).list_for_user(user.id)
|
||||
return [
|
||||
OrganizationWithRole(
|
||||
id=org.id, name=org.name, slug=org.slug,
|
||||
owner_user_id=org.owner_user_id, role=role.value,
|
||||
)
|
||||
for org, role in orgs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{org_id}", response_model=OrganizationWithRole)
|
||||
async def get_organization(context: OrgContext = Depends(get_org_context)) -> OrganizationWithRole:
|
||||
return OrganizationWithRole(
|
||||
id=context.organization.id,
|
||||
name=context.organization.name,
|
||||
slug=context.organization.slug,
|
||||
owner_user_id=context.organization.owner_user_id,
|
||||
role=context.membership.role.value,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{org_id}/members", response_model=list[MemberOut])
|
||||
async def list_members(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[MemberOut]:
|
||||
memberships = await MembershipRepository(session).list_for_org(context.org_id)
|
||||
users = UserRepository(session)
|
||||
out: list[MemberOut] = []
|
||||
for membership in memberships:
|
||||
member_user = await users.get(membership.user_id)
|
||||
out.append(
|
||||
MemberOut(
|
||||
id=membership.id,
|
||||
user_id=membership.user_id,
|
||||
email=member_user.email if member_user else "",
|
||||
name=member_user.display_name if member_user else "",
|
||||
role=membership.role.value,
|
||||
status=membership.status.value,
|
||||
created_at=membership.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{org_id}/invites", response_model=InviteOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_invite(
|
||||
payload: InviteRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> InviteOut:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("دعوت عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
if ROLE_RANK[payload.role] >= ROLE_RANK[Role.OWNER]:
|
||||
raise ValidationError("نقش owner از طریق دعوت قابل واگذاری نیست.")
|
||||
|
||||
raw_token = generate_token()
|
||||
invite = await InviteRepository(session).create(
|
||||
Invite(
|
||||
organization_id=context.org_id,
|
||||
email=str(payload.email),
|
||||
role=payload.role,
|
||||
token_hash=hash_token(raw_token),
|
||||
invited_by_user_id=context.user.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(days=INVITE_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_INVITE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="invite",
|
||||
target_id=invite.id,
|
||||
metadata={"email": invite.email, "role": invite.role.value},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return InviteOut(
|
||||
id=invite.id,
|
||||
email=invite.email,
|
||||
role=invite.role.value,
|
||||
expires_at=invite.expires_at,
|
||||
invite_token=raw_token,
|
||||
invite_url=f"/accept-invite?token={raw_token}",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{org_id}/members/{member_id}", response_model=MemberOut)
|
||||
async def update_member(
|
||||
member_id: str,
|
||||
payload: UpdateMemberRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemberOut:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("تغییر نقش عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
|
||||
memberships = MembershipRepository(session)
|
||||
target = await memberships.get_by_id(member_id)
|
||||
if target is None or target.organization_id != context.org_id:
|
||||
raise NotFoundError("عضو یافت نشد.")
|
||||
|
||||
# An admin may not outrank themselves or promote anyone to owner.
|
||||
if target.role == Role.OWNER and context.user.id != target.user_id:
|
||||
raise PermissionDeniedError("نقش owner فقط توسط خودش قابل تغییر است.")
|
||||
if payload.role is not None and ROLE_RANK[payload.role] > ROLE_RANK[context.membership.role]:
|
||||
raise PermissionDeniedError("نمیتوانید نقشی بالاتر از نقش خود واگذار کنید.")
|
||||
|
||||
if payload.role is not None:
|
||||
if target.role == Role.OWNER and await memberships.count_owners(context.org_id) <= 1:
|
||||
raise ValidationError("آخرین owner سازمان را نمیتوان تنزل داد.")
|
||||
await memberships.set_role(member_id, payload.role)
|
||||
if payload.status is not None:
|
||||
await memberships.set_status(member_id, payload.status)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_ROLE_CHANGE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="membership",
|
||||
target_id=member_id,
|
||||
metadata={
|
||||
"role": payload.role.value if payload.role else None,
|
||||
"status": payload.status.value if payload.status else None,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
updated = await memberships.get_by_id(member_id)
|
||||
assert updated is not None
|
||||
member_user = await UserRepository(session).get(updated.user_id)
|
||||
return MemberOut(
|
||||
id=updated.id,
|
||||
user_id=updated.user_id,
|
||||
email=member_user.email if member_user else "",
|
||||
name=member_user.display_name if member_user else "",
|
||||
role=updated.role.value,
|
||||
status=updated.status.value,
|
||||
created_at=updated.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{org_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def remove_member(
|
||||
member_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
if not context.membership.has(CAN_MANAGE_MEMBERS):
|
||||
raise PermissionDeniedError("حذف عضو نیازمند دسترسی مدیریت اعضا است.")
|
||||
|
||||
memberships = MembershipRepository(session)
|
||||
target = await memberships.get_by_id(member_id)
|
||||
if target is None or target.organization_id != context.org_id:
|
||||
raise NotFoundError("عضو یافت نشد.")
|
||||
if target.role == Role.OWNER:
|
||||
raise ValidationError("owner سازمان را نمیتوان حذف کرد؛ ابتدا نقش را تغییر دهید.")
|
||||
|
||||
await memberships.remove(member_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=AuditAction.MEMBER_REMOVE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="membership",
|
||||
target_id=member_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{org_id}/audit", response_model=list[AuditEventOut])
|
||||
async def list_audit(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AuditEventOut]:
|
||||
if not context.membership.has(CAN_VIEW_AUDIT):
|
||||
raise PermissionDeniedError("مشاهدهٔ audit log نیازمند دسترسی مدیر است.")
|
||||
events = await AuditRepository(session).list_for_organization(
|
||||
organization_id=context.org_id, limit=limit, offset=offset
|
||||
)
|
||||
return [
|
||||
AuditEventOut(
|
||||
id=e.id, action=e.action, actor_user_id=e.actor_user_id,
|
||||
target_type=e.target_type, target_id=e.target_id, created_at=e.created_at,
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Project, repository and preview-domain endpoints.
|
||||
|
||||
All routes are nested under ``/organizations/{org_id}/projects`` so the tenant
|
||||
context is resolved by ``get_org_context`` before any handler body runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.project_service import ProjectService
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import NotFoundError
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS
|
||||
from app.interfaces.http.deps import OrgContext, client_meta, get_org_context, get_project_service
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/organizations/{org_id}/projects", tags=["projects"]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str
|
||||
default_branch: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
description: str = Field(default="", max_length=2000)
|
||||
|
||||
|
||||
class RepoOut(BaseModel):
|
||||
id: str
|
||||
provider: str
|
||||
owner: str
|
||||
repo_name: str
|
||||
clone_url: str
|
||||
web_url: str
|
||||
default_branch: str
|
||||
private: bool = True
|
||||
|
||||
|
||||
class RepoStatusOut(BaseModel):
|
||||
exists: bool
|
||||
private: bool
|
||||
default_branch: str
|
||||
branches: list[str]
|
||||
open_pull_requests: int
|
||||
|
||||
|
||||
class CreateBranchRequest(BaseModel):
|
||||
branch_name: str = Field(min_length=1, max_length=120)
|
||||
from_branch: str = Field(default="main", max_length=120)
|
||||
|
||||
|
||||
class BranchOut(BaseModel):
|
||||
name: str
|
||||
sha: str
|
||||
|
||||
|
||||
class OpenPullRequestRequest(BaseModel):
|
||||
head: str = Field(min_length=1, max_length=120)
|
||||
base: str = Field(default="main", max_length=120)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
body: str = Field(default="", max_length=10_000)
|
||||
|
||||
|
||||
class PullRequestOut(BaseModel):
|
||||
number: int
|
||||
url: str
|
||||
state: str
|
||||
|
||||
|
||||
class PreviewDomainOut(BaseModel):
|
||||
id: str
|
||||
host: str
|
||||
port: int
|
||||
status: str
|
||||
protected: bool
|
||||
url: str
|
||||
|
||||
|
||||
class CreatePreviewDomainRequest(BaseModel):
|
||||
subdomain: str = Field(min_length=3, max_length=40)
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
|
||||
|
||||
class AllocatePortRequest(BaseModel):
|
||||
service_name: str = Field(default="web", max_length=80)
|
||||
|
||||
|
||||
class PortOut(BaseModel):
|
||||
port: int
|
||||
service_name: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Projects
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project(
|
||||
payload: CreateProjectRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ProjectOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
|
||||
project = await service.create_project(
|
||||
organization_id=context.org_id,
|
||||
organization_slug=context.organization.slug,
|
||||
created_by_user_id=context.user.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.PROJECT_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="project", target_id=project.id,
|
||||
metadata={"name": project.name, "slug": project.slug}, ip=ip, user_agent=ua,
|
||||
)
|
||||
return ProjectOut(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectOut])
|
||||
async def list_projects(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[ProjectOut]:
|
||||
projects = await service.projects.list_for_org(context.org_id, limit=limit, offset=offset)
|
||||
return [
|
||||
ProjectOut(
|
||||
id=p.id, organization_id=p.organization_id, name=p.name, slug=p.slug,
|
||||
description=p.description, default_branch=p.default_branch,
|
||||
created_at=p.created_at,
|
||||
)
|
||||
for p in projects
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectOut)
|
||||
async def get_project(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> ProjectOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
return ProjectOut(
|
||||
id=project.id, organization_id=project.organization_id, name=project.name,
|
||||
slug=project.slug, description=project.description,
|
||||
default_branch=project.default_branch, created_at=project.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Repository
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/ensure", response_model=RepoOut)
|
||||
async def ensure_repo(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RepoOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
|
||||
repo = await service.ensure_repository(
|
||||
organization_slug=context.organization.slug, project=project
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.REPO_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="repository", target_id=repo.id,
|
||||
metadata={"repo_name": repo.repo_name, "provider": repo.provider},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return RepoOut(
|
||||
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
||||
clone_url=repo.clone_url, web_url=repo.web_url,
|
||||
default_branch=repo.default_branch, private=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/repo", response_model=RepoOut)
|
||||
async def get_repo(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> RepoOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
repo = await service.repos.get_for_project(project.id)
|
||||
if repo is None:
|
||||
raise NotFoundError("مخزنی برای این پروژه ثبت نشده است.")
|
||||
# clone_url is a plain https URL; credentials are never included.
|
||||
return RepoOut(
|
||||
id=repo.id, provider=repo.provider, owner=repo.owner, repo_name=repo.repo_name,
|
||||
clone_url=repo.clone_url, web_url=repo.web_url,
|
||||
default_branch=repo.default_branch, private=True,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/repo/status", response_model=RepoStatusOut)
|
||||
async def get_repo_status(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> RepoStatusOut:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
status_result = await service.repo_status(project=project)
|
||||
return RepoStatusOut(
|
||||
exists=status_result.exists,
|
||||
private=status_result.private,
|
||||
default_branch=status_result.default_branch,
|
||||
branches=list(status_result.branches),
|
||||
open_pull_requests=status_result.open_pull_requests,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/branches", response_model=BranchOut)
|
||||
async def create_branch(
|
||||
project_id: str,
|
||||
payload: CreateBranchRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> BranchOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
branch = await service.git.create_branch(
|
||||
project_id=project.id, branch_name=payload.branch_name, from_branch=payload.from_branch
|
||||
)
|
||||
return BranchOut(name=branch.name, sha=branch.sha)
|
||||
|
||||
|
||||
@router.post("/{project_id}/repo/pull-requests", response_model=PullRequestOut)
|
||||
async def open_pull_request(
|
||||
project_id: str,
|
||||
payload: OpenPullRequestRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> PullRequestOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
pr = await service.git.open_pull_request(
|
||||
project_id=project.id, head=payload.head, base=payload.base,
|
||||
title=payload.title, body=payload.body,
|
||||
)
|
||||
return PullRequestOut(number=pr.number, url=pr.url, state=pr.state)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preview domains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/{project_id}/preview/ports", response_model=PortOut)
|
||||
async def allocate_port(
|
||||
project_id: str,
|
||||
payload: AllocatePortRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> PortOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
port = await service.allocate_port(project=project, service_name=payload.service_name)
|
||||
return PortOut(port=port, service_name=payload.service_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/preview/domains",
|
||||
response_model=PreviewDomainOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_preview_domain(
|
||||
project_id: str,
|
||||
payload: CreatePreviewDomainRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PreviewDomainOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
|
||||
record = await service.create_preview_domain(
|
||||
project=project,
|
||||
requested_subdomain=payload.subdomain,
|
||||
created_by_user_id=context.user.id,
|
||||
port=payload.port,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.DOMAIN_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="preview_domain",
|
||||
target_id=record["id"],
|
||||
metadata={"host": record["host"], "port": record["port"]},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return PreviewDomainOut(
|
||||
id=record["id"], host=record["host"], port=record["port"],
|
||||
status=record["status"], protected=record["protected"], url=record["url"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/preview/domains", response_model=list[PreviewDomainOut])
|
||||
async def list_preview_domains(
|
||||
project_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
) -> list[PreviewDomainOut]:
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
records = await service.list_preview_domains(project=project)
|
||||
return [
|
||||
PreviewDomainOut(
|
||||
id=r["id"], host=r["host"], port=r["port"], status=r["status"],
|
||||
protected=r["protected"], url=r["url"],
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{project_id}/preview/domains/{domain_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
async def delete_preview_domain(
|
||||
project_id: str,
|
||||
domain_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: ProjectService = Depends(get_project_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
project = await service.get_or_404(organization_id=context.org_id, project_id=project_id)
|
||||
await service.remove_preview_domain(project=project, domain_id=domain_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.DOMAIN_DELETE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="preview_domain", target_id=domain_id,
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Sandbox Gateway endpoints.
|
||||
|
||||
Note what is **absent**: there is no route that accepts a shell command and runs
|
||||
it on the host. Every execution goes to ``SandboxProviderPort``, is bounded by a
|
||||
policy timeout and output cap, and is metered into the usage ledger.
|
||||
|
||||
Running a command requires ``sandbox.use``; tearing a workspace down requires
|
||||
``projects.manage`` because it destroys work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.sandbox_service import SandboxService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_PROJECTS, CAN_USE_SANDBOX
|
||||
from app.domain.sandbox.models import NetworkPolicy
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_sandbox_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SandboxTemplateOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
provider: str
|
||||
image: str
|
||||
description: str
|
||||
default_cpu: float
|
||||
default_memory_mb: int
|
||||
default_disk_mb: int
|
||||
|
||||
|
||||
class WorkspaceOut(BaseModel):
|
||||
id: str
|
||||
project_id: str
|
||||
template_id: str
|
||||
provider: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateWorkspaceRequest(BaseModel):
|
||||
project_id: str
|
||||
template_id: str
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
status: str
|
||||
started_at: datetime
|
||||
last_active_at: datetime
|
||||
|
||||
|
||||
class StartSessionRequest(BaseModel):
|
||||
workspace_id: str
|
||||
|
||||
|
||||
class CommandRunOut(BaseModel):
|
||||
id: str
|
||||
command: str
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
truncated: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunCommandRequest(BaseModel):
|
||||
command: str = Field(min_length=1, max_length=4_000)
|
||||
|
||||
|
||||
class FileReadOut(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
|
||||
|
||||
class ReadFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
|
||||
|
||||
class WriteFileRequest(BaseModel):
|
||||
path: str = Field(min_length=1, max_length=1_000)
|
||||
content: str = Field(max_length=1_000_000)
|
||||
|
||||
|
||||
class SnapshotOut(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CreateSnapshotRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class PreviewUrlOut(BaseModel):
|
||||
port: int
|
||||
url: str
|
||||
|
||||
|
||||
class ResourceLimitOut(BaseModel):
|
||||
cpu: float
|
||||
memory_mb: int
|
||||
disk_mb: int
|
||||
timeout_seconds: int
|
||||
max_output_bytes: int
|
||||
network_policy: str
|
||||
|
||||
|
||||
class SetLimitsRequest(BaseModel):
|
||||
scope: str = "organization"
|
||||
scope_id: str | None = None
|
||||
cpu: float = Field(default=1.0, gt=0, le=16)
|
||||
memory_mb: int = Field(default=1024, ge=128, le=65_536)
|
||||
disk_mb: int = Field(default=2048, ge=256, le=1_048_576)
|
||||
timeout_seconds: int = Field(default=120, ge=1, le=900)
|
||||
max_output_bytes: int = Field(default=200_000, ge=1024, le=2_000_000)
|
||||
network_policy: NetworkPolicy = NetworkPolicy.DENY_ALL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates & limits
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[SandboxTemplateOut])
|
||||
async def list_templates(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[SandboxTemplateOut]:
|
||||
templates = await service.list_templates()
|
||||
return [
|
||||
SandboxTemplateOut(
|
||||
id=t.id, name=t.name, provider=str(t.provider), image=t.image,
|
||||
description=t.description, default_cpu=t.default_cpu,
|
||||
default_memory_mb=t.default_memory_mb, default_disk_mb=t.default_disk_mb,
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.get("/limits", response_model=ResourceLimitOut)
|
||||
async def get_limits(
|
||||
project_id: str | None = Query(default=None),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
limit = await service.effective_limits(
|
||||
organization_id=context.org_id, project_id=project_id
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/limits", response_model=ResourceLimitOut)
|
||||
async def set_limits(
|
||||
payload: SetLimitsRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> ResourceLimitOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
limit = await service.set_limits(
|
||||
scope=payload.scope, scope_id=payload.scope_id or context.org_id,
|
||||
cpu=payload.cpu, memory_mb=payload.memory_mb, disk_mb=payload.disk_mb,
|
||||
timeout_seconds=payload.timeout_seconds,
|
||||
max_output_bytes=payload.max_output_bytes,
|
||||
network_policy=payload.network_policy,
|
||||
)
|
||||
return ResourceLimitOut(
|
||||
cpu=limit.cpu, memory_mb=limit.memory_mb, disk_mb=limit.disk_mb,
|
||||
timeout_seconds=limit.timeout_seconds, max_output_bytes=limit.max_output_bytes,
|
||||
network_policy=str(limit.network_policy),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Workspaces
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/workspaces", response_model=WorkspaceOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_workspace(
|
||||
payload: CreateWorkspaceRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
workspace = await service.create_workspace(
|
||||
organization_id=context.org_id,
|
||||
project_id=payload.project_id,
|
||||
template_id=payload.template_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action="sandbox.workspace_create", organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace", target_id=workspace.id,
|
||||
metadata={"project_id": workspace.project_id, "provider": str(workspace.provider)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/workspaces", response_model=list[WorkspaceOut])
|
||||
async def list_workspaces(
|
||||
project_id: str = Query(min_length=1),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[WorkspaceOut]:
|
||||
workspaces = await service.list_workspaces(
|
||||
organization_id=context.org_id,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return [
|
||||
WorkspaceOut(
|
||||
id=w.id, project_id=w.project_id, template_id=w.template_id,
|
||||
provider=str(w.provider), status=str(w.status), created_at=w.created_at,
|
||||
)
|
||||
for w in workspaces
|
||||
]
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}", response_model=WorkspaceOut)
|
||||
async def destroy_workspace(
|
||||
workspace_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> WorkspaceOut:
|
||||
context.require(CAN_MANAGE_PROJECTS)
|
||||
workspace = await service.destroy_workspace(
|
||||
organization_id=context.org_id, workspace_id=workspace_id
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_DESTROY, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="workspace",
|
||||
target_id=workspace.id, ip=ip, user_agent=ua,
|
||||
)
|
||||
return WorkspaceOut(
|
||||
id=workspace.id, project_id=workspace.project_id,
|
||||
template_id=workspace.template_id, provider=str(workspace.provider),
|
||||
status=str(workspace.status), created_at=workspace.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions", response_model=SessionOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def start_session(
|
||||
payload: StartSessionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.start_session(
|
||||
organization_id=context.org_id, workspace_id=payload.workspace_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/stop", response_model=SessionOut)
|
||||
async def stop_session(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SessionOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
sandbox_session = await service.stop_session(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
return SessionOut(
|
||||
id=sandbox_session.id, workspace_id=sandbox_session.workspace_id,
|
||||
status=str(sandbox_session.status), started_at=sandbox_session.started_at,
|
||||
last_active_at=sandbox_session.last_active_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Commands & files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/commands", response_model=CommandRunOut)
|
||||
async def run_command(
|
||||
session_id: str,
|
||||
payload: RunCommandRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> CommandRunOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
|
||||
run, _result = await service.run_command(
|
||||
organization_id=context.org_id,
|
||||
session_id=session_id,
|
||||
command=payload.command,
|
||||
user_id=context.user.id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.SANDBOX_COMMAND, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="sandbox_session",
|
||||
target_id=session_id,
|
||||
# The command itself is recorded, but never a secret from the environment.
|
||||
metadata={"exit_code": run.exit_code, "duration_ms": run.duration_ms},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return CommandRunOut(
|
||||
id=run.id, command=run.command, exit_code=run.exit_code,
|
||||
stdout=run.stdout_ref, stderr=run.stderr_ref, duration_ms=run.duration_ms,
|
||||
truncated=run.truncated, created_at=run.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/commands", response_model=list[CommandRunOut])
|
||||
async def list_commands(
|
||||
session_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> list[CommandRunOut]:
|
||||
await service.get_session_or_404(
|
||||
organization_id=context.org_id, session_id=session_id
|
||||
)
|
||||
runs = await service.list_commands(session_id=session_id)
|
||||
return [
|
||||
CommandRunOut(
|
||||
id=r.id, command=r.command, exit_code=r.exit_code, stdout=r.stdout_ref,
|
||||
stderr=r.stderr_ref, duration_ms=r.duration_ms, truncated=r.truncated,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in runs
|
||||
]
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/read", response_model=FileReadOut)
|
||||
async def read_file(
|
||||
session_id: str,
|
||||
payload: ReadFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> FileReadOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
content = await service.read_file(
|
||||
organization_id=context.org_id, session_id=session_id, path=payload.path
|
||||
)
|
||||
return FileReadOut(path=payload.path, content=content)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/files/write", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def write_file(
|
||||
session_id: str,
|
||||
payload: WriteFileRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> None:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
await service.write_file(
|
||||
organization_id=context.org_id, session_id=session_id,
|
||||
path=payload.path, content=payload.content,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshots & preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/snapshots",
|
||||
response_model=SnapshotOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_snapshot(
|
||||
session_id: str,
|
||||
payload: CreateSnapshotRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> SnapshotOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
snapshot = await service.snapshot(
|
||||
organization_id=context.org_id, session_id=session_id, name=payload.name
|
||||
)
|
||||
return SnapshotOut(
|
||||
id=snapshot.id, name=snapshot.name, created_at=snapshot.created_at
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/preview-url", response_model=PreviewUrlOut)
|
||||
async def get_preview_url(
|
||||
session_id: str,
|
||||
port: int = Query(ge=1, le=65535),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: SandboxService = Depends(get_sandbox_service),
|
||||
) -> PreviewUrlOut:
|
||||
context.require(CAN_USE_SANDBOX)
|
||||
preview = await service.get_preview_url(
|
||||
organization_id=context.org_id, session_id=session_id, port=port
|
||||
)
|
||||
return PreviewUrlOut(port=preview.port, url=preview.url)
|
||||
@@ -0,0 +1,803 @@
|
||||
"""AI Workforce, Manager Chat, Tasks and Runs.
|
||||
|
||||
Two things are load-bearing in this module:
|
||||
|
||||
* **Model identity never leaves.** ``EmployeeOut`` and ``RunOut`` carry a routing
|
||||
policy id at most. There is no field capable of holding a model or provider
|
||||
name, so a future refactor cannot accidentally leak one.
|
||||
* **Every route resolves its tenant** through ``get_org_context``, and every
|
||||
nested id (employee, thread, task, run) is re-scoped to that tenant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.workforce_service import WorkforceService
|
||||
from app.core.database import get_session
|
||||
from app.domain.audit.models import AuditAction
|
||||
from app.domain.identity.models import CAN_MANAGE_EMPLOYEES, CAN_RUN_TASKS
|
||||
from app.domain.workforce.models import (
|
||||
EmployeeStatus,
|
||||
RoleKey,
|
||||
TaskStatus,
|
||||
)
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
get_workforce_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/organizations/{org_id}", tags=["workforce"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentTemplateOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
role_key: str
|
||||
description: str
|
||||
default_tools_json: list[str]
|
||||
|
||||
|
||||
class RoutingChoiceOut(BaseModel):
|
||||
"""A routing policy as a *customer* may see it.
|
||||
|
||||
Name and description only. Deliberately omits ``mode`` and every field that
|
||||
could hint at which model sits behind the choice.
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
class ToolSpecOut(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
requires_approval: bool
|
||||
destructive: bool
|
||||
category: str
|
||||
|
||||
|
||||
class SetToolPermissionRequest(BaseModel):
|
||||
allowed: bool
|
||||
|
||||
|
||||
class DecideApprovalRequest(BaseModel):
|
||||
approve: bool
|
||||
note: str = Field(default="", max_length=1000)
|
||||
|
||||
|
||||
class EmployeeOut(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
template_id: str | None
|
||||
name: str
|
||||
role_key: str
|
||||
status: str
|
||||
# A routing policy id — never a model or provider name.
|
||||
routing_policy_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmployeeDetailOut(EmployeeOut):
|
||||
prompt_versions: list[PromptVersionOut]
|
||||
tool_permissions: list[ToolPermissionOut]
|
||||
open_task_count: int
|
||||
|
||||
|
||||
class CreateEmployeeRequest(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=160)
|
||||
role_key: RoleKey
|
||||
template_id: str | None = None
|
||||
project_id: str | None = None
|
||||
routing_policy_id: str | None = None
|
||||
|
||||
|
||||
class PromptVersionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
version: int
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ToolPermissionOut(BaseModel):
|
||||
tool_key: str
|
||||
allowed: bool
|
||||
|
||||
|
||||
# ``EmployeeDetailOut`` refers to the two models above by name. With
|
||||
# ``from __future__ import annotations`` every annotation is a string, so Pydantic
|
||||
# must be told to resolve them once the names exist.
|
||||
EmployeeDetailOut.model_rebuild()
|
||||
|
||||
|
||||
class UpdateEmployeeRequest(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=160)
|
||||
status: EmployeeStatus | None = None
|
||||
project_id: str | None = None
|
||||
routing_policy_id: str | None = None
|
||||
|
||||
|
||||
class CreatePromptVersionRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20_000)
|
||||
|
||||
|
||||
class ThreadOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
title: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MessageOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
thread_id: str
|
||||
role: str
|
||||
content: str
|
||||
employee_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ThreadDetailOut(ThreadOut):
|
||||
messages: list[MessageOut]
|
||||
|
||||
|
||||
class CreateThreadRequest(BaseModel):
|
||||
title: str = Field(default="", max_length=255)
|
||||
project_id: str | None = None
|
||||
|
||||
|
||||
class PostMessageRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=20_000)
|
||||
employee_id: str | None = None
|
||||
|
||||
|
||||
class PostMessageResponse(BaseModel):
|
||||
user_message: MessageOut
|
||||
assistant_message: MessageOut
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
project_id: str | None
|
||||
thread_id: str | None
|
||||
title: str
|
||||
description: str
|
||||
status: str
|
||||
assigned_employee_id: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
title: str = Field(min_length=2, max_length=255)
|
||||
description: str = Field(default="", max_length=10_000)
|
||||
project_id: str | None = None
|
||||
thread_id: str | None = None
|
||||
assigned_employee_id: str | None = None
|
||||
|
||||
|
||||
class RunOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
task_id: str
|
||||
employee_id: str | None
|
||||
status: str
|
||||
error: str | None
|
||||
started_at: datetime | None
|
||||
finished_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunStepOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
index: int
|
||||
kind: str
|
||||
title: str
|
||||
payload_json: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ToolCallOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
tool_key: str
|
||||
status: str
|
||||
requires_approval: bool
|
||||
arguments_json: dict
|
||||
result_json: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ArtifactOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
name: str
|
||||
kind: str
|
||||
content_ref: str
|
||||
size_bytes: int
|
||||
run_id: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class RunDetailOut(RunOut):
|
||||
steps: list[RunStepOut]
|
||||
tool_calls: list[ToolCallOut]
|
||||
artifacts: list[ArtifactOut]
|
||||
|
||||
|
||||
class TaskDetailOut(TaskOut):
|
||||
runs: list[RunOut]
|
||||
|
||||
|
||||
class StartRunRequest(BaseModel):
|
||||
employee_id: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _employee_out(employee) -> EmployeeOut:
|
||||
return EmployeeOut(
|
||||
id=employee.id, organization_id=employee.organization_id,
|
||||
project_id=employee.project_id, template_id=employee.template_id,
|
||||
name=employee.name, role_key=str(employee.role_key),
|
||||
status=str(employee.status), routing_policy_id=employee.routing_policy_id,
|
||||
created_at=employee.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _thread_out(thread) -> ThreadOut:
|
||||
return ThreadOut(
|
||||
id=thread.id, organization_id=thread.organization_id,
|
||||
project_id=thread.project_id, title=thread.title, created_at=thread.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _task_out(task) -> TaskOut:
|
||||
return TaskOut(
|
||||
id=task.id, organization_id=task.organization_id, project_id=task.project_id,
|
||||
thread_id=task.thread_id, title=task.title, description=task.description,
|
||||
status=str(task.status), assigned_employee_id=task.assigned_employee_id,
|
||||
created_at=task.created_at, updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _run_out(run) -> RunOut:
|
||||
return RunOut(
|
||||
id=run.id, task_id=run.task_id, employee_id=run.employee_id,
|
||||
status=str(run.status), error=run.error, started_at=run.started_at,
|
||||
finished_at=run.finished_at, created_at=run.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/agent-templates", response_model=list[AgentTemplateOut])
|
||||
async def list_agent_templates(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[AgentTemplateOut]:
|
||||
templates = await service.list_templates()
|
||||
return [
|
||||
AgentTemplateOut(
|
||||
id=t.id, name=t.name, role_key=str(t.role_key), description=t.description,
|
||||
default_tools_json=list(t.default_tools_json),
|
||||
)
|
||||
for t in templates
|
||||
]
|
||||
|
||||
|
||||
@router.get("/routing-choices", response_model=list[RoutingChoiceOut])
|
||||
async def list_routing_choices(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[RoutingChoiceOut]:
|
||||
"""Let a customer pick *how* an employee should work, without naming a model."""
|
||||
policies = await service.list_routing_choices()
|
||||
return [
|
||||
RoutingChoiceOut(id=p.id, name=p.name, description=p.description) for p in policies
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employees", response_model=EmployeeOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_employee(
|
||||
payload: CreateEmployeeRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> EmployeeOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
employee = await service.create_employee(
|
||||
organization_id=context.org_id,
|
||||
created_by_user_id=context.user.id,
|
||||
name=payload.name,
|
||||
role_key=payload.role_key,
|
||||
template_id=payload.template_id,
|
||||
project_id=payload.project_id,
|
||||
routing_policy_id=payload.routing_policy_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.EMPLOYEE_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||
metadata={"name": employee.name, "role_key": str(employee.role_key)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return _employee_out(employee)
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[EmployeeOut])
|
||||
async def list_employees(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[EmployeeOut]:
|
||||
return [
|
||||
_employee_out(e)
|
||||
for e in await service.list_employees(context.org_id, limit=limit, offset=offset)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}", response_model=EmployeeDetailOut)
|
||||
async def get_employee(
|
||||
employee_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> EmployeeDetailOut:
|
||||
employee = await service.get_employee_or_404(
|
||||
organization_id=context.org_id, employee_id=employee_id
|
||||
)
|
||||
base = _employee_out(employee)
|
||||
return EmployeeDetailOut(
|
||||
**base.model_dump(),
|
||||
prompt_versions=[
|
||||
PromptVersionOut(
|
||||
id=v.id, version=v.version, content=v.content, created_at=v.created_at
|
||||
)
|
||||
for v in await service.list_prompt_versions(employee.id)
|
||||
],
|
||||
tool_permissions=[
|
||||
ToolPermissionOut(tool_key=p.tool_key, allowed=p.allowed)
|
||||
for p in await service.list_tool_permissions(employee.id)
|
||||
],
|
||||
open_task_count=await service.tasks.count_open_for_employee(employee.id),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||
async def update_employee(
|
||||
employee_id: str,
|
||||
payload: UpdateEmployeeRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> EmployeeOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
|
||||
employee = await service.update_employee(
|
||||
organization_id=context.org_id,
|
||||
employee_id=employee_id,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
project_id=payload.project_id,
|
||||
routing_policy_id=payload.routing_policy_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.EMPLOYEE_UPDATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="employee", target_id=employee.id,
|
||||
metadata={"status": str(employee.status)},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return _employee_out(employee)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/employees/{employee_id}/prompt-versions",
|
||||
response_model=PromptVersionOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_prompt_version(
|
||||
employee_id: str,
|
||||
payload: CreatePromptVersionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> PromptVersionOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
# Confirms tenant ownership before writing anything.
|
||||
await service.get_employee_or_404(
|
||||
organization_id=context.org_id, employee_id=employee_id
|
||||
)
|
||||
version = await service.add_prompt_version(
|
||||
employee_id=employee_id, content=payload.content,
|
||||
created_by_user_id=context.user.id,
|
||||
)
|
||||
return PromptVersionOut(
|
||||
id=version.id, version=version.version, content=version.content,
|
||||
created_at=version.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threads & messages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads", response_model=ThreadOut, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
async def create_thread(
|
||||
payload: CreateThreadRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ThreadOut:
|
||||
thread = await service.create_thread(
|
||||
organization_id=context.org_id, created_by_user_id=context.user.id,
|
||||
title=payload.title, project_id=payload.project_id,
|
||||
)
|
||||
return _thread_out(thread)
|
||||
|
||||
|
||||
@router.get("/threads", response_model=list[ThreadOut])
|
||||
async def list_threads(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[ThreadOut]:
|
||||
return [
|
||||
_thread_out(t)
|
||||
for t in await service.list_threads(context.org_id, limit=limit, offset=offset)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/threads/{thread_id}", response_model=ThreadDetailOut)
|
||||
async def get_thread(
|
||||
thread_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ThreadDetailOut:
|
||||
thread = await service.get_thread_or_404(
|
||||
organization_id=context.org_id, thread_id=thread_id
|
||||
)
|
||||
messages = await service.list_messages(thread.id)
|
||||
base = _thread_out(thread)
|
||||
return ThreadDetailOut(
|
||||
**base.model_dump(),
|
||||
messages=[
|
||||
MessageOut(
|
||||
id=m.id, thread_id=m.thread_id, role=str(m.role), content=m.content,
|
||||
employee_id=m.employee_id, created_at=m.created_at,
|
||||
)
|
||||
for m in messages
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/threads/{thread_id}/messages",
|
||||
response_model=PostMessageResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def post_message(
|
||||
thread_id: str,
|
||||
payload: PostMessageRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> PostMessageResponse:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
user_message, assistant_message = await service.post_user_message(
|
||||
organization_id=context.org_id,
|
||||
thread_id=thread_id,
|
||||
content=payload.content,
|
||||
user_id=context.user.id,
|
||||
employee_id=payload.employee_id,
|
||||
)
|
||||
return PostMessageResponse(
|
||||
user_message=MessageOut(
|
||||
id=user_message.id, thread_id=user_message.thread_id,
|
||||
role=str(user_message.role), content=user_message.content,
|
||||
employee_id=user_message.employee_id, created_at=user_message.created_at,
|
||||
),
|
||||
assistant_message=MessageOut(
|
||||
id=assistant_message.id, thread_id=assistant_message.thread_id,
|
||||
role=str(assistant_message.role), content=assistant_message.content,
|
||||
employee_id=assistant_message.employee_id,
|
||||
created_at=assistant_message.created_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
payload: CreateTaskRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TaskOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
task = await service.create_task(
|
||||
organization_id=context.org_id,
|
||||
created_by_user_id=context.user.id,
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
project_id=payload.project_id,
|
||||
thread_id=payload.thread_id,
|
||||
assigned_employee_id=payload.assigned_employee_id,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.TASK_CREATE, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="task", target_id=task.id,
|
||||
metadata={"title": task.title}, ip=ip, user_agent=ua,
|
||||
)
|
||||
return _task_out(task)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[TaskOut])
|
||||
async def list_tasks(
|
||||
status_filter: TaskStatus | None = Query(default=None, alias="status"),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[TaskOut]:
|
||||
tasks = await service.list_tasks(
|
||||
context.org_id, status=status_filter, limit=limit, offset=offset
|
||||
)
|
||||
return [_task_out(t) for t in tasks]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}", response_model=TaskDetailOut)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> TaskDetailOut:
|
||||
task = await service.get_task_or_404(organization_id=context.org_id, task_id=task_id)
|
||||
runs = await service.list_task_runs(task.id)
|
||||
base = _task_out(task)
|
||||
return TaskDetailOut(**base.model_dump(), runs=[_run_out(r) for r in runs])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tasks/{task_id}/runs",
|
||||
response_model=RunDetailOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def start_run(
|
||||
task_id: str,
|
||||
payload: StartRunRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> RunDetailOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
run = await service.start_run(
|
||||
organization_id=context.org_id,
|
||||
task_id=task_id,
|
||||
user_id=context.user.id,
|
||||
employee_id=payload.employee_id,
|
||||
)
|
||||
return await _run_detail(service, context.org_id, run.id)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=RunDetailOut)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> RunDetailOut:
|
||||
return await _run_detail(service, context.org_id, run_id)
|
||||
|
||||
|
||||
@router.post("/runs/{run_id}/cancel", response_model=RunDetailOut)
|
||||
async def cancel_run(
|
||||
run_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> RunDetailOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
run = await service.cancel_run(organization_id=context.org_id, run_id=run_id)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.RUN_CANCEL, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="run", target_id=run.id,
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return await _run_detail(service, context.org_id, run.id)
|
||||
|
||||
|
||||
@router.get("/approvals/pending", response_model=list[ToolCallOut])
|
||||
async def list_pending_approvals(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[ToolCallOut]:
|
||||
calls = await service.list_pending_approvals(context.org_id)
|
||||
return [
|
||||
ToolCallOut(
|
||||
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||
requires_approval=c.requires_approval,
|
||||
arguments_json=dict(c.arguments_json), result_json=dict(c.result_json),
|
||||
created_at=c.created_at,
|
||||
)
|
||||
for c in calls
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool catalog & approvals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/tools", response_model=list[ToolSpecOut])
|
||||
async def list_tools(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> list[ToolSpecOut]:
|
||||
"""The catalog of tools an employee can be granted."""
|
||||
return [
|
||||
ToolSpecOut(
|
||||
key=spec.key, label=spec.label, description=spec.description,
|
||||
requires_approval=spec.requires_approval, destructive=spec.destructive,
|
||||
category=spec.category,
|
||||
)
|
||||
for spec in service.tool_catalog()
|
||||
]
|
||||
|
||||
|
||||
@router.put(
|
||||
"/employees/{employee_id}/tools/{tool_key}", response_model=ToolPermissionOut
|
||||
)
|
||||
async def set_tool_permission(
|
||||
employee_id: str,
|
||||
tool_key: str,
|
||||
payload: SetToolPermissionRequest,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
) -> ToolPermissionOut:
|
||||
context.require(CAN_MANAGE_EMPLOYEES)
|
||||
permission = await service.set_tool_permission(
|
||||
organization_id=context.org_id,
|
||||
employee_id=employee_id,
|
||||
tool_key=tool_key,
|
||||
allowed=payload.allowed,
|
||||
)
|
||||
return ToolPermissionOut(tool_key=permission.tool_key, allowed=permission.allowed)
|
||||
|
||||
|
||||
@router.post("/approvals/{tool_call_id}/decide", response_model=ToolCallOut)
|
||||
async def decide_approval(
|
||||
tool_call_id: str,
|
||||
payload: DecideApprovalRequest,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: WorkforceService = Depends(get_workforce_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ToolCallOut:
|
||||
context.require(CAN_RUN_TASKS)
|
||||
|
||||
call = await service.decide_approval(
|
||||
organization_id=context.org_id,
|
||||
tool_call_id=tool_call_id,
|
||||
approve=payload.approve,
|
||||
decided_by_user_id=context.user.id,
|
||||
note=payload.note,
|
||||
)
|
||||
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session, action=AuditAction.APPROVAL_DECISION, organization_id=context.org_id,
|
||||
actor_user_id=context.user.id, target_type="tool_call", target_id=call.id,
|
||||
metadata={"tool_key": call.tool_key, "approved": payload.approve},
|
||||
ip=ip, user_agent=ua,
|
||||
)
|
||||
return ToolCallOut(
|
||||
id=call.id, tool_key=call.tool_key, status=str(call.status),
|
||||
requires_approval=call.requires_approval,
|
||||
arguments_json=dict(call.arguments_json), result_json=dict(call.result_json),
|
||||
created_at=call.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def _run_detail(
|
||||
service: WorkforceService, organization_id: str, run_id: str
|
||||
) -> RunDetailOut:
|
||||
run, steps, tool_calls, artifacts = await service.get_run_or_404(
|
||||
organization_id=organization_id, run_id=run_id
|
||||
)
|
||||
base = _run_out(run)
|
||||
return RunDetailOut(
|
||||
**base.model_dump(),
|
||||
steps=[
|
||||
RunStepOut(
|
||||
id=s.id, index=s.index, kind=s.kind, title=s.title,
|
||||
payload_json=dict(s.payload_json), created_at=s.created_at,
|
||||
)
|
||||
for s in steps
|
||||
],
|
||||
tool_calls=[
|
||||
ToolCallOut(
|
||||
id=c.id, tool_key=c.tool_key, status=str(c.status),
|
||||
requires_approval=c.requires_approval,
|
||||
arguments_json=dict(c.arguments_json),
|
||||
result_json=dict(c.result_json), created_at=c.created_at,
|
||||
)
|
||||
for c in tool_calls
|
||||
],
|
||||
artifacts=[
|
||||
ArtifactOut(
|
||||
id=a.id, name=a.name, kind=a.kind, content_ref=a.content_ref,
|
||||
size_bytes=a.size_bytes, run_id=a.run_id, created_at=a.created_at,
|
||||
)
|
||||
for a in artifacts
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user