Проект разделен на два репозитория
This commit is contained in:
@@ -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))
|
||||
Reference in New Issue
Block a user