Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
@@ -0,0 +1,505 @@
import hashlib
import json
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Query, Request, Response
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import AuthError
from app.db import UserIdentity
from app.notification_models import NotificationSource
from app.notification_schemas import (
NotificationCancelRequest,
NotificationCreateRequest,
UploadCompleteRequest,
UploadInitRequest,
)
from app.notification_service import (
apply_read_or_hide,
authenticate_source,
cancel_notification,
catalog,
complete_upload,
create_notification,
discard_upload,
document_download,
init_upload,
invoke_cta_state,
list_notifications,
list_uploads,
notification_dto,
owned_notification,
press_button,
public_notifications,
unread_count,
)
from app.schemas import TextMessageRequest
from app.services import (
AuditContext,
DomainError,
SettingsSnapshot,
create_dialog,
load_settings,
resolve_user,
send_message,
)
router = APIRouter()
async def db_session(request: Request):
async for value in request.app.state.db.session():
yield value
Session = Annotated[AsyncSession, Depends(db_session)]
async def user_dependency(
request: Request,
db: Session,
authorization: Annotated[str | None, Header()] = None,
) -> UserIdentity:
if not authorization or not authorization.startswith("Bearer "):
raise AuthError()
principal = await request.app.state.jwks.validate(authorization.removeprefix("Bearer ").strip())
return await resolve_user(db, principal)
User = Annotated[UserIdentity, Depends(user_dependency)]
async def snapshot_dependency(db: Session) -> SettingsSnapshot:
return await load_settings(db)
Snapshot = Annotated[SettingsSnapshot, Depends(snapshot_dependency)]
async def source_dependency(
db: Session, authorization: Annotated[str | None, Header()] = None
) -> NotificationSource:
return await authenticate_source(db, authorization)
Source = Annotated[NotificationSource, Depends(source_dependency)]
def context(request: Request, ux_session: str | None = None) -> AuditContext:
try:
ux_id = uuid.UUID(ux_session) if ux_session else None
except ValueError:
raise DomainError("validation_error", 400, "X-Ux-Session-Id must be UUID") from None
return AuditContext(
request_id=request.state.request_id,
trace_id=request.state.trace_id,
ux_session_id=ux_id,
user_agent_hash=request.state.user_agent_hash,
client_ip=None,
)
async def rate_limit(
request: Request,
identity: str,
route: str,
limit: tuple[int, int],
*,
fail_closed: bool,
) -> None:
key = request.app.state.rate_limiter.key("notification", identity, route, limit[1])
try:
retry_after = await request.app.state.rate_limiter.consume(key, *limit)
except Exception:
if fail_closed:
raise DomainError(
"dependency_unavailable", 503, "Rate limit service is unavailable"
) from None
return
if retry_after:
raise DomainError(
"rate_limit_exceeded",
429,
"Rate limit exceeded",
{"retry_after": retry_after},
)
@router.get("/api/v1/public/notifications", tags=["notifications"])
async def public_list(request: Request, db: Session, settings: Snapshot):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
return {
"items": await public_notifications(db, settings.integer("notification.home.max_items"))
}
@router.get("/api/v1/public/notification-types", tags=["notifications"])
async def type_catalog(
request: Request,
response: Response,
db: Session,
settings: Snapshot,
if_none_match: Annotated[str | None, Header()] = None,
):
await rate_limit(
request,
request.client.host if request.client else "unknown",
"public",
settings.limit("rate_limit.notifications_public.per_ip"),
fail_closed=False,
)
items = await catalog(db)
digest = hashlib.sha256(
json.dumps(items, default=str, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
etag = f'"{digest}"'
if if_none_match == etag:
return Response(status_code=304, headers={"ETag": etag})
response.headers["ETag"] = etag
response.headers["Cache-Control"] = "public, max-age=3600"
return {"items": items}
@router.get("/api/v1/notifications", tags=["notifications"])
async def personal_list(
request: Request,
db: Session,
user: User,
settings: Snapshot,
place: str = Query(pattern="^(home|center)$"),
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
limit = settings.integer(
"notification.home.max_items" if place == "home" else "notification.center.max_items"
)
return {"items": await list_notifications(db, user.id, place, limit)}
@router.get("/api/v1/notifications/counter", tags=["notifications"])
async def counter(request: Request, db: Session, user: User, settings: Snapshot):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
return {
"unread_count": await unread_count(
db, user.id, settings.integer("notification.center.max_items")
)
}
@router.get("/api/v1/notifications/{notification_id}", tags=["notifications"])
async def detail(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"read",
settings.limit("rate_limit.notifications_read.per_user"),
fail_closed=False,
)
item, kind = await owned_notification(db, user.id, notification_id)
if kind.cta_action != "open_detail":
raise DomainError("not_found", 404, "Resource was not found")
return await notification_dto(db, item, kind)
@router.post("/api/v1/notifications/{notification_id}/read", tags=["notifications"])
async def mark_read(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"read",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/hide", tags=["notifications"])
async def hide(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await apply_read_or_hide(
db,
user.id,
notification_id,
"hide",
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post(
"/api/v1/notifications/{notification_id}/buttons/{button_code}",
tags=["notifications"],
)
async def button(
notification_id: uuid.UUID,
button_code: str,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
return await press_button(
db,
user.id,
notification_id,
button_code,
settings,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/notifications/{notification_id}/cta", tags=["notifications"])
async def cta(
notification_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"action",
settings.limit("rate_limit.notifications_action.per_user"),
fail_closed=False,
)
audit_context = context(request, x_ux_session_id)
item, kind = await owned_notification(db, user.id, notification_id, action=True)
chat_result = None
if kind.cta_action == "send_chat_message":
dialog, _status = await create_dialog(
db, user, audit_context, f"notification-dialog:{item.id}"
)
chat_result = await send_message(
db,
user,
uuid.UUID(str(dialog["dialog_id"])),
TextMessageRequest(content_kind="text", text=item.chat_message_text or ""),
f"notification-message:{item.id}",
audit_context,
settings,
request.app.state.settings,
request.app.state.safety,
request.app.state.openlines,
request.app.state.s3,
request.app.state.realtime,
)
_item, _kind, result = await invoke_cta_state(
db,
user.id,
notification_id,
settings,
request.app.state.realtime,
audit_context,
chat_result=chat_result,
)
return result
@router.get(
"/api/v1/notifications/{notification_id}/documents/{document_id}/download-url",
tags=["notifications"],
)
async def download(
notification_id: uuid.UUID,
document_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
x_ux_session_id: Annotated[str | None, Header(alias="X-Ux-Session-Id")] = None,
):
await rate_limit(
request,
str(user.id),
"download",
settings.limit("rate_limit.download_url.per_user"),
fail_closed=True,
)
return await document_download(
db,
user.id,
notification_id,
document_id,
settings,
request.app.state.s3,
request.app.state.realtime,
context(request, x_ux_session_id),
)
@router.post("/api/v1/uploads/init", status_code=201, tags=["uploads"])
async def upload_init(
body: UploadInitRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await init_upload(db, user.id, body, settings, request.app.state.s3)
@router.post("/api/v1/uploads/{draft_id}/complete", tags=["uploads"])
async def upload_complete(
draft_id: uuid.UUID,
body: UploadCompleteRequest,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
return await complete_upload(
db,
user.id,
draft_id,
body,
request.app.state.s3,
request.app.state.safety,
request.state.request_id,
)
@router.get("/api/v1/uploads", tags=["uploads"])
async def uploads(
request: Request,
db: Session,
user: User,
settings: Snapshot,
context_type: str,
context_id: uuid.UUID,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
if context_type != "notification":
raise DomainError("validation_error", 400, "Unsupported upload context")
return {"items": await list_uploads(db, user.id, context_type, context_id)}
@router.delete("/api/v1/uploads/{draft_id}", status_code=204, tags=["uploads"])
async def upload_delete(
draft_id: uuid.UUID,
request: Request,
db: Session,
user: User,
settings: Snapshot,
):
await rate_limit(
request,
str(user.id),
"upload",
settings.limit("rate_limit.notification_upload.per_user"),
fail_closed=True,
)
await discard_upload(db, user.id, draft_id, request.app.state.s3)
return Response(status_code=204)
@router.post("/internal/notifications/v1/notifications", tags=["internal"])
async def internal_create(
body: NotificationCreateRequest,
request: Request,
db: Session,
source: Source,
):
result, status = await create_notification(
db,
body,
source,
request.app.state.s3,
request.app.state.realtime,
context(request),
)
return JSONResponse(json.loads(json.dumps(result, default=str)), status_code=status)
@router.post("/internal/notifications/v1/notifications/cancel", tags=["internal"])
async def internal_cancel(
body: NotificationCancelRequest,
request: Request,
db: Session,
source: Source,
):
return await cancel_notification(db, body, source, request.app.state.realtime, context(request))