نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user