Добавлены уведомления
This commit is contained in:
@@ -295,6 +295,9 @@ class S3Client:
|
||||
Key=key,
|
||||
)
|
||||
|
||||
async def delete(self, bucket: str, key: str) -> None:
|
||||
await asyncio.to_thread(self.client.delete_object, Bucket=bucket, Key=key)
|
||||
|
||||
async def upload_inbound(
|
||||
self,
|
||||
http: httpx.AsyncClient,
|
||||
|
||||
@@ -50,6 +50,8 @@ from app.integrations import (
|
||||
S3Client,
|
||||
SafetyClient,
|
||||
)
|
||||
from app.notification_routes import router as notification_router
|
||||
from app.notification_service import synchronize_source_tokens
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.schemas import (
|
||||
AttachmentCompleteRequest,
|
||||
@@ -128,6 +130,7 @@ async def lifespan(app: FastAPI):
|
||||
try:
|
||||
async with app.state.db.sessions() as db:
|
||||
app.state.snapshot = await load_settings(db)
|
||||
await synchronize_source_tokens(db)
|
||||
except Exception:
|
||||
structlog.get_logger().warning("settings.warmup_failed")
|
||||
settings_task = asyncio.create_task(refresh_settings_cache(app))
|
||||
@@ -153,6 +156,7 @@ app = FastAPI(
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.include_router(notification_router)
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
@@ -243,7 +247,7 @@ async def request_context(request: Request, call_next: Any) -> Response:
|
||||
response.headers["Access-Control-Allow-Headers"] = (
|
||||
"Authorization,Content-Type,Idempotency-Key,X-Request-ID,X-Ux-Session-Id"
|
||||
)
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
|
||||
response.headers["Vary"] = "Origin"
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
@@ -416,7 +420,7 @@ async def ready(request: Request, db: Session):
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
revision = await db.scalar(text("SELECT version_num FROM han_app.alembic_version LIMIT 1"))
|
||||
if revision != "0005_otp_settings":
|
||||
if revision != "0008_notifications_v1":
|
||||
raise RuntimeError("unexpected database revision")
|
||||
await load_settings(db)
|
||||
components["postgres"] = "ok"
|
||||
@@ -498,6 +502,14 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
||||
"allowed_mime_types": settings.strings("chat.attachments.allowed_mime_types"),
|
||||
"max_size_mb": settings.integer("chat.attachments.max_size_mb"),
|
||||
},
|
||||
"notification": {
|
||||
"carousel_autoplay_enabled": settings.boolean(
|
||||
"notification.carousel.autoplay_enabled"
|
||||
),
|
||||
"carousel_autoplay_interval_ms": settings.integer(
|
||||
"notification.carousel.autoplay_interval_ms"
|
||||
),
|
||||
},
|
||||
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
|
||||
}
|
||||
|
||||
@@ -1062,6 +1074,7 @@ async def realtime(websocket: WebSocket):
|
||||
await websocket.close(code=4400)
|
||||
return
|
||||
ids = {uuid.UUID(value) for value in payload.get("dialog_ids", [])[:100]}
|
||||
notifications = bool(payload.get("notifications", False))
|
||||
count = await db.scalar(
|
||||
select(func.count(Dialog.id)).where(
|
||||
Dialog.id.in_(ids),
|
||||
@@ -1076,10 +1089,16 @@ async def realtime(websocket: WebSocket):
|
||||
event_task.cancel()
|
||||
if event_stream:
|
||||
await event_stream.aclose()
|
||||
event_stream = websocket.app.state.realtime.events(ids)
|
||||
event_stream = websocket.app.state.realtime.events(
|
||||
ids, user.id, notifications
|
||||
)
|
||||
event_task = asyncio.create_task(anext(event_stream))
|
||||
await websocket.send_json(
|
||||
{"type": "subscribed", "dialog_ids": [str(value) for value in ids]}
|
||||
{
|
||||
"type": "subscribed",
|
||||
"dialog_ids": [str(value) for value in ids],
|
||||
"notifications": notifications,
|
||||
}
|
||||
)
|
||||
except (AuthError, DomainError, ValueError):
|
||||
await websocket.close(code=4401)
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
SmallInteger,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import SCHEMA, Base, Common
|
||||
|
||||
|
||||
def uuid7() -> uuid.UUID:
|
||||
"""Generate an RFC 9562 UUIDv7 without relying on Python 3.14."""
|
||||
timestamp_ms = int(time.time_ns() // 1_000_000) & ((1 << 48) - 1)
|
||||
value = timestamp_ms << 80
|
||||
value |= 0x7 << 76
|
||||
value |= secrets.randbits(12) << 64
|
||||
value |= 0b10 << 62
|
||||
value |= secrets.randbits(62)
|
||||
return uuid.UUID(int=value)
|
||||
|
||||
|
||||
class NotificationCtaAction(Common, Base):
|
||||
__tablename__ = "notification_cta_actions"
|
||||
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str] = mapped_column(String(255))
|
||||
requires_auth: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
required_instance_fields: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), default=list, server_default="{}"
|
||||
)
|
||||
|
||||
|
||||
class NotificationButton(Common, Base):
|
||||
__tablename__ = "notification_buttons"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code"),
|
||||
CheckConstraint("NOT applies_hidden_ttl OR sets_hidden"),
|
||||
CheckConstraint("NOT submits_documents OR close_reason IS NOT NULL"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
applies_hidden_ttl: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
submits_documents: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class NotificationColorToken(Common, Base):
|
||||
__tablename__ = "notification_color_tokens"
|
||||
__table_args__ = (UniqueConstraint("code"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str] = mapped_column(String(255))
|
||||
sort_order: Mapped[int] = mapped_column(SmallInteger)
|
||||
|
||||
|
||||
class NotificationType(Common, Base):
|
||||
__tablename__ = "notification_types"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("code"),
|
||||
CheckConstraint("contour IN ('G','P')"),
|
||||
CheckConstraint("contour <> 'G' OR countable = false"),
|
||||
CheckConstraint("contour <> 'G' OR cta_action <> 'open_detail'"),
|
||||
CheckConstraint(
|
||||
"cta_action <> 'open_detail' OR (cta_sets_hidden = false AND cta_close_reason IS NULL)"
|
||||
),
|
||||
CheckConstraint(
|
||||
"cta_action = 'open_detail' OR "
|
||||
"(documents_allowed = false AND hide_on_document_download = false "
|
||||
"AND required_detail_blocks = '{}')"
|
||||
),
|
||||
CheckConstraint("NOT hide_on_document_download OR documents_allowed"),
|
||||
CheckConstraint("cta_action <> 'open_detail' OR button_primary_code IS NOT NULL"),
|
||||
CheckConstraint(
|
||||
"cta_action = 'open_detail' OR "
|
||||
"(button_primary_code IS NULL AND button_secondary_code IS NULL)"
|
||||
),
|
||||
CheckConstraint("button_secondary_code IS NULL OR button_primary_code IS NOT NULL"),
|
||||
CheckConstraint(
|
||||
"button_secondary_code IS NULL OR button_secondary_code <> button_primary_code"
|
||||
),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
contour: Mapped[str] = mapped_column(String(1))
|
||||
priority: Mapped[int] = mapped_column(SmallInteger)
|
||||
countable: Mapped[bool] = mapped_column(Boolean)
|
||||
label: Mapped[str] = mapped_column(String(64))
|
||||
color_token: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_color_tokens.code", ondelete="RESTRICT")
|
||||
)
|
||||
icon_code: Mapped[str | None] = mapped_column(String(32))
|
||||
cta_text: Mapped[str] = mapped_column(String(64))
|
||||
cta_action: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_cta_actions.code", ondelete="RESTRICT")
|
||||
)
|
||||
cta_sets_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
cta_close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
button_primary_code: Mapped[str | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
|
||||
)
|
||||
button_secondary_code: Mapped[str | None] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_buttons.code", ondelete="RESTRICT")
|
||||
)
|
||||
hidden_ttl_days: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
documents_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
hide_on_document_download: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
required_detail_blocks: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String(64)), default=list, server_default="{}"
|
||||
)
|
||||
|
||||
|
||||
class NotificationSource(Common, Base):
|
||||
__tablename__ = "notification_sources"
|
||||
__table_args__ = (UniqueConstraint("code"), UniqueConstraint("token_hash"), {"schema": SCHEMA})
|
||||
code: Mapped[str] = mapped_column(String(32))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
token_hash: Mapped[str] = mapped_column(String(128))
|
||||
token_rotated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class Notification(Common, Base):
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("source", "external_id", name="uq_notifications_source_key"),
|
||||
CheckConstraint("lifecycle_status IN ('active','closed')"),
|
||||
CheckConstraint("visibility IN ('visible','hidden')"),
|
||||
CheckConstraint(
|
||||
"close_reason IS NULL OR close_reason IN "
|
||||
"('user_done','docs_submitted','offer_accepted','paid','expired','cancelled')"
|
||||
),
|
||||
CheckConstraint(
|
||||
"lifecycle_status <> 'closed' OR (close_reason IS NOT NULL AND closed_at IS NOT NULL)"
|
||||
),
|
||||
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
|
||||
Index(
|
||||
"ix_notifications_user_active",
|
||||
"user_id",
|
||||
"lifecycle_status",
|
||||
"visibility",
|
||||
"notification_datetime",
|
||||
"id",
|
||||
),
|
||||
Index("ix_notifications_expire", "date_expired"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
notification_type: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
|
||||
)
|
||||
source: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_sources.code", ondelete="RESTRICT")
|
||||
)
|
||||
external_id: Mapped[str] = mapped_column(String(128))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
header: Mapped[str] = mapped_column(String(255))
|
||||
text: Mapped[str | None] = mapped_column(String(1024))
|
||||
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
payment_url: Mapped[str | None] = mapped_column(Text)
|
||||
details: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
details_schema_version: Mapped[int] = mapped_column(SmallInteger, default=1)
|
||||
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
visibility: Mapped[str] = mapped_column(String(16), default="visible")
|
||||
is_read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
close_reason: Mapped[str | None] = mapped_column(String(32))
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class GuestNotification(Common, Base):
|
||||
__tablename__ = "guest_notifications"
|
||||
__table_args__ = (
|
||||
CheckConstraint("lifecycle_status IN ('active','closed')"),
|
||||
CheckConstraint("old_price IS NULL OR price IS NOT NULL"),
|
||||
CheckConstraint("instruction_url IS NULL OR instruction_url LIKE 'https://%'"),
|
||||
Index("ix_guest_notifications_active", "lifecycle_status", "notification_datetime", "id"),
|
||||
Index("ix_guest_notifications_expire", "date_expired"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
notification_type: Mapped[str] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notification_types.code", ondelete="RESTRICT")
|
||||
)
|
||||
notification_datetime: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
header: Mapped[str] = mapped_column(String(255))
|
||||
text: Mapped[str | None] = mapped_column(String(1024))
|
||||
priority_override: Mapped[int | None] = mapped_column(SmallInteger)
|
||||
date_expired: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
old_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2))
|
||||
instruction_url: Mapped[str | None] = mapped_column(Text)
|
||||
chat_message_text: Mapped[str | None] = mapped_column(String(1024))
|
||||
lifecycle_status: Mapped[str] = mapped_column(String(16), default="active")
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class NotificationDocument(Common, Base):
|
||||
__tablename__ = "notification_documents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("notification_id", "document_id"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
notification_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.notifications.id", ondelete="RESTRICT")
|
||||
)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.documents.id", ondelete="RESTRICT")
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(SmallInteger, default=0)
|
||||
download_url_issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class ClientUploadDraft(Base):
|
||||
__tablename__ = "client_upload_drafts"
|
||||
__table_args__ = (
|
||||
CheckConstraint("context_type IN ('notification')"),
|
||||
CheckConstraint("size_bytes > 0"),
|
||||
CheckConstraint("scan_status IN ('pending','clean','infected','failed')"),
|
||||
CheckConstraint("state IN ('draft','submitted','discarded')"),
|
||||
Index("ix_client_upload_drafts_context", "user_id", "context_type", "context_id"),
|
||||
Index("ix_client_upload_drafts_scan", "scan_status", "updated_at"),
|
||||
Index("ix_client_upload_drafts_created", "created_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid7)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
context_type: Mapped[str] = mapped_column(String(32))
|
||||
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str | None] = mapped_column(String(64))
|
||||
scan_status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||
upload_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
state: Mapped[str] = mapped_column(String(16), default="draft")
|
||||
submission_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class ClientDocument(Common, Base):
|
||||
__tablename__ = "client_documents"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("storage_bucket", "object_key"),
|
||||
UniqueConstraint("source_draft_id"),
|
||||
Index("ix_client_documents_context", "context_type", "context_id"),
|
||||
Index("ix_client_documents_user_submitted", "user_id", "submitted_at"),
|
||||
{"schema": SCHEMA},
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey(f"{SCHEMA}.user_identities.id", ondelete="RESTRICT")
|
||||
)
|
||||
context_type: Mapped[str] = mapped_column(String(32))
|
||||
context_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
submission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
source_draft_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
original_file_name: Mapped[str] = mapped_column(String(255))
|
||||
safe_file_name: Mapped[str] = mapped_column(String(255))
|
||||
mime_type: Mapped[str] = mapped_column(String(128))
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger)
|
||||
checksum_sha256: Mapped[str] = mapped_column(String(64))
|
||||
storage_bucket: Mapped[str] = mapped_column(String(255))
|
||||
object_key: Mapped[str] = mapped_column(String(1024))
|
||||
submitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
@@ -0,0 +1,504 @@
|
||||
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,
|
||||
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))
|
||||
@@ -0,0 +1,72 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, HttpUrl, model_validator
|
||||
|
||||
from app.schemas import StrictModel
|
||||
|
||||
|
||||
class TodoItem(StrictModel):
|
||||
number: int
|
||||
text: str = Field(min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class CompanyDocumentInput(StrictModel):
|
||||
object_key: str = Field(min_length=1, max_length=1024)
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
checksum_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class NotificationDetailsInput(StrictModel):
|
||||
deadline: datetime | None = None
|
||||
details_header: str | None = Field(default=None, max_length=255)
|
||||
details_text: str | None = Field(default=None, max_length=4000)
|
||||
todo_header: str | None = Field(default=None, max_length=255)
|
||||
todo_plan: list[TodoItem] | None = Field(default=None, min_length=1)
|
||||
send_documents: bool = False
|
||||
documents: list[CompanyDocumentInput] | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class NotificationCreateRequest(StrictModel):
|
||||
user_id: uuid.UUID
|
||||
notification_type: str = Field(min_length=1, max_length=32)
|
||||
source: str = Field(min_length=1, max_length=32)
|
||||
external_id: str = Field(min_length=1, max_length=128)
|
||||
notification_datetime: datetime
|
||||
header: str = Field(min_length=1, max_length=255)
|
||||
text: str | None = Field(default=None, max_length=1024)
|
||||
priority_override: int | None = None
|
||||
date_expired: datetime | None = None
|
||||
price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
|
||||
old_price: Decimal | None = Field(default=None, ge=0, max_digits=12, decimal_places=2)
|
||||
payment_url: HttpUrl | None = None
|
||||
chat_message_text: str | None = Field(default=None, max_length=1024)
|
||||
details: NotificationDetailsInput | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def prices(self) -> "NotificationCreateRequest":
|
||||
if self.old_price is not None and self.price is None:
|
||||
raise ValueError("old_price requires price")
|
||||
return self
|
||||
|
||||
|
||||
class NotificationCancelRequest(StrictModel):
|
||||
source: str = Field(min_length=1, max_length=32)
|
||||
external_id: str = Field(min_length=1, max_length=128)
|
||||
close_reason: Literal["cancelled", "paid"]
|
||||
|
||||
|
||||
class UploadInitRequest(StrictModel):
|
||||
context_type: Literal["notification"]
|
||||
context_id: uuid.UUID
|
||||
file_name: str = Field(min_length=1, max_length=255)
|
||||
mime_type: str = Field(min_length=1, max_length=128)
|
||||
size_bytes: int = Field(gt=0)
|
||||
|
||||
|
||||
class UploadCompleteRequest(StrictModel):
|
||||
checksum: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,10 @@ from typing import Any
|
||||
|
||||
from redis.asyncio import Redis
|
||||
|
||||
CHANNEL_PREFIX = "han:rt:dialog:"
|
||||
DIALOG_CHANNEL_PREFIX = "han:rt:dialog:"
|
||||
USER_CHANNEL_PREFIX = "han:rt:user:"
|
||||
# Backward-compatible name used by existing chat integrations.
|
||||
CHANNEL_PREFIX = DIALOG_CHANNEL_PREFIX
|
||||
|
||||
|
||||
class LocalFanout:
|
||||
@@ -36,28 +39,58 @@ class RealtimeFanout:
|
||||
|
||||
async def publish(self, event: dict[str, Any]) -> None:
|
||||
event = {"event_id": str(uuid.uuid4()), **event}
|
||||
channel = CHANNEL_PREFIX + str(event["dialog_id"])
|
||||
channel = DIALOG_CHANNEL_PREFIX + str(event["dialog_id"])
|
||||
try:
|
||||
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||
except Exception:
|
||||
await self.local.publish(event)
|
||||
|
||||
async def events(self, dialog_ids: set[uuid.UUID]) -> AsyncIterator[dict[str, Any]]:
|
||||
channels = [CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
|
||||
async def publish_user(self, user_id: uuid.UUID, event: dict[str, Any]) -> None:
|
||||
event = {"event_id": str(uuid.uuid4()), "_user_id": str(user_id), **event}
|
||||
channel = USER_CHANNEL_PREFIX + str(user_id)
|
||||
try:
|
||||
await self.redis.publish(channel, json.dumps(event, default=str, separators=(",", ":")))
|
||||
except Exception:
|
||||
await self.local.publish(event)
|
||||
|
||||
async def events(
|
||||
self,
|
||||
dialog_ids: set[uuid.UUID],
|
||||
user_id: uuid.UUID | None = None,
|
||||
notifications: bool = False,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
channels = [DIALOG_CHANNEL_PREFIX + str(dialog_id) for dialog_id in dialog_ids]
|
||||
if notifications and user_id is not None:
|
||||
channels.append(USER_CHANNEL_PREFIX + str(user_id))
|
||||
if not channels:
|
||||
await asyncio.Event().wait()
|
||||
return
|
||||
pubsub = self.redis.pubsub()
|
||||
try:
|
||||
await pubsub.subscribe(*channels)
|
||||
except Exception:
|
||||
await pubsub.aclose()
|
||||
async for event in self.local.subscribe():
|
||||
if uuid.UUID(str(event["dialog_id"])) in dialog_ids:
|
||||
yield event
|
||||
dialog_match = event.get("dialog_id") and uuid.UUID(
|
||||
str(event["dialog_id"])
|
||||
) in dialog_ids
|
||||
user_match = (
|
||||
notifications
|
||||
and user_id is not None
|
||||
and event.get("_user_id") == str(user_id)
|
||||
)
|
||||
if dialog_match or user_match:
|
||||
payload = dict(event)
|
||||
payload.pop("_user_id", None)
|
||||
yield payload
|
||||
return
|
||||
try:
|
||||
while True:
|
||||
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1)
|
||||
if message:
|
||||
yield json.loads(message["data"])
|
||||
payload = json.loads(message["data"])
|
||||
payload.pop("_user_id", None)
|
||||
yield payload
|
||||
else:
|
||||
await asyncio.sleep(0)
|
||||
finally:
|
||||
|
||||
@@ -87,6 +87,19 @@ REQUIRED_SETTINGS = {
|
||||
"ux.session.idle_timeout_minutes",
|
||||
"security.cors.allowed_origins",
|
||||
"security.public_cache.max_age_seconds",
|
||||
"notification.home.max_items",
|
||||
"notification.center.max_items",
|
||||
"notification.carousel.autoplay_enabled",
|
||||
"notification.carousel.autoplay_interval_ms",
|
||||
"notification.hidden.default_ttl_days",
|
||||
"notification.documents.max_files",
|
||||
"notification.instruction.allowed_hosts",
|
||||
"notification.expire_job.run_at",
|
||||
"notification.upload_draft.ttl_days",
|
||||
"rate_limit.notifications_read.per_user",
|
||||
"rate_limit.notifications_action.per_user",
|
||||
"rate_limit.notification_upload.per_user",
|
||||
"rate_limit.notifications_public.per_ip",
|
||||
} | OTP_SETTING_KEYS
|
||||
|
||||
|
||||
|
||||
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
|
||||
cursor_hmac_secret: SecretStr = Field(alias="CURSOR_HMAC_SECRET")
|
||||
trusted_proxy_cidrs: str = Field(default="127.0.0.1/32", alias="TRUSTED_PROXY_CIDRS")
|
||||
worker_poll_interval_sec: float = Field(default=2, alias="WORKER_POLL_INTERVAL_SEC")
|
||||
notifications_token_producer_test: SecretStr | None = Field(
|
||||
default=None, alias="NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||
)
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db import Database, DeliveryOutbox, Dialog, Message, MessageAttachment, SafetyTask
|
||||
from app.integrations import (
|
||||
@@ -15,8 +15,10 @@ from app.integrations import (
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.notification_models import ClientUploadDraft
|
||||
from app.notification_service import expire_notifications
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.services import publish_dialog_status, publish_message_status
|
||||
from app.services import load_settings, publish_dialog_status, publish_message_status
|
||||
from app.settings import Settings, get_settings
|
||||
|
||||
log = structlog.get_logger()
|
||||
@@ -222,6 +224,75 @@ async def loop(kind: str) -> None:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def notification_expire_loop() -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
try:
|
||||
while True:
|
||||
async with db.sessions() as session:
|
||||
snapshot = await load_settings(session)
|
||||
run_at = snapshot.values["notification.expire_job.run_at"]
|
||||
hour, minute = (int(value) for value in run_at.split(":", 1))
|
||||
now = datetime.now(UTC)
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
await asyncio.sleep((target - now).total_seconds())
|
||||
async with db.sessions() as session:
|
||||
personal, guest = await expire_notifications(session)
|
||||
log.info(
|
||||
"notification.expired_batch",
|
||||
personal_count=personal,
|
||||
guest_count=guest,
|
||||
)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
async def notification_draft_cleanup_once(db: Database, s3: S3Client) -> int:
|
||||
async with db.sessions() as session:
|
||||
snapshot = await load_settings(session)
|
||||
cutoff = datetime.now(UTC) - timedelta(
|
||||
days=snapshot.integer("notification.upload_draft.ttl_days")
|
||||
)
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(ClientUploadDraft)
|
||||
.where(ClientUploadDraft.created_at < cutoff)
|
||||
.with_for_update(skip_locked=True)
|
||||
.limit(100)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
for row in rows:
|
||||
if row.state != "submitted":
|
||||
if row.quarantine_object_key:
|
||||
await s3.delete_quarantine(row.quarantine_object_key)
|
||||
elif row.object_key:
|
||||
await s3.delete(row.storage_bucket, row.object_key)
|
||||
await session.execute(
|
||||
delete(ClientUploadDraft).where(ClientUploadDraft.id == row.id)
|
||||
)
|
||||
await session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def notification_draft_cleanup_loop() -> None:
|
||||
settings = get_settings()
|
||||
db = Database(settings.database_url)
|
||||
s3 = S3Client(settings)
|
||||
try:
|
||||
while True:
|
||||
count = await notification_draft_cleanup_once(db, s3)
|
||||
if count < 100:
|
||||
await asyncio.sleep(86400)
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
def delivery_main() -> None:
|
||||
asyncio.run(loop("delivery"))
|
||||
|
||||
@@ -232,3 +303,11 @@ def safety_main() -> None:
|
||||
|
||||
def cleanup_main() -> None:
|
||||
asyncio.run(loop("cleanup"))
|
||||
|
||||
|
||||
def notification_expire_main() -> None:
|
||||
asyncio.run(notification_expire_loop())
|
||||
|
||||
|
||||
def notification_draft_cleanup_main() -> None:
|
||||
asyncio.run(notification_draft_cleanup_loop())
|
||||
|
||||
Reference in New Issue
Block a user