diff --git a/.env.staging.example b/.env.staging.example index c5c29d2..ec25849 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -42,3 +42,11 @@ encryption_key=super_secret_encryption_key totp_issuer=MultiAI + + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback +GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile + +FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key \ No newline at end of file diff --git a/.gitignore b/.gitignore index d87d619..5cc1fe4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,6 @@ __pycache__ db/schema.sql .vscode/settings.json - +multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json db.txt diff --git a/app/container.py b/app/container.py index 96b8f26..fd94a4f 100644 --- a/app/container.py +++ b/app/container.py @@ -12,8 +12,10 @@ from app.service.staff_notifications import StaffNotificationsService from app.service.staff_user import StaffUserService +from app.service.audit import AuditService from app.service.upload_requests import UploadRequestsService from app.service.users import AuthService +from app.service.user_notification import UserNotificationService from db.generated import devices as device_queries from db.generated import photos as photo_queries from db.generated import session as session_queries @@ -27,7 +29,11 @@ from db.generated import events as event_queries from db.generated import eventParticipant as participant_queries from db.generated import stuff_user as staff_queries +from db.generated import notifications as notification_queries +from db.generated import audit as audit_queries from app.service.event import EventService +from app.worker.notification.notification_queue import NotificationQueue +from app.worker.notification.settings import NotifSetting class Container: def __init__( @@ -49,6 +55,8 @@ def __init__( self.upload_request_photo_querier = upload_request_photo_queries.AsyncQuerier(conn) self.photo_querier = photo_queries.AsyncQuerier(conn) self.staff_notification_querier = staff_notification_queries.AsyncQuerier(conn) + self.notification_querier = notification_queries.AsyncQuerier(conn) + self.audit_querier = audit_queries.AsyncQuerier(conn) self.event_querier = event_queries.AsyncQuerier(conn) self.participant_querier = participant_queries.AsyncQuerier(conn) self.staff_querier = staff_queries.AsyncQuerier(conn) @@ -94,6 +102,18 @@ def __init__( staff_notifications_service=self.staff_notifications_service, ) + notification_queue = NotificationQueue(settings=NotifSetting) + + self.user_notifications_service = UserNotificationService( + notification_querier=self.notification_querier, + notification_queue=notification_queue, + ) + + self.audit_service = AuditService( + audit_querier=self.audit_querier, + user_querier=self.user_querier, + ) + self.staff_user_service = StaffUserService() self.staff_user_service.init( @@ -104,9 +124,6 @@ def __init__( p_querier=self.participant_querier, ) - - - async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), ) -> Container: diff --git a/app/core/config.py b/app/core/config.py index 3267862..671f6fb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -46,6 +46,7 @@ class Settings(BaseSettings): ) FACE_ENCRYPTION_KEY: str + FIREBASE_CREDENTIALS_PATH: str = "multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json" class Config: env_file = ".env" diff --git a/app/core/constant.py b/app/core/constant.py index 7d3f191..0cae9dc 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -4,6 +4,22 @@ class RedisKey(str, Enum): UserSession = "user_session" UserSessionByUser = "user_session:{user_id}" + INVALID_TOKEN_SET_KEY= "notifications:invalid_tokens" + + +NOTIFICATION_EVENT_SUBJECT = "notification_event" +AUDIT_EVENT_SUBJECT = "audit.event" + + +class AuditEventType(str, Enum): + USER_SIGNUP = "user.signup" + USER_LOGIN = "user.login" + USER_LOGOUT = "user.logout" + UPLOAD_REQUEST_CREATED = "upload_request.created" + UPLOAD_REQUEST_APPROVED = "upload_request.approved" + UPLOAD_REQUEST_REJECTED = "upload_request.rejected" + + IMAGE_ALLOWED_TYPES = { "image/jpeg", diff --git a/app/infra/nats.py b/app/infra/nats.py index d2d2454..5a9a101 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -7,14 +7,19 @@ from pydantic import BaseModel from app.core.config import settings +from app.core.constant import NOTIFICATION_EVENT_SUBJECT, AUDIT_EVENT_SUBJECT class Message(BaseModel): data: dict[str, Any] + + class NatsSubjects(Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + NOTIFICATION_EVENT = NOTIFICATION_EVENT_SUBJECT + AUDIT_EVENT = AUDIT_EVENT_SUBJECT STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" @@ -24,13 +29,19 @@ class NatsClient: _js: Optional[JetStreamContext] = None @staticmethod - async def connect() -> None: + async def connect( + *, + host: str | None = None, + port: int | None = None, + user: str | None = None, + password: str | None = None, + ) -> None: if NatsClient._nc is None: nc = NATS() await nc.connect( - servers=[f"nats://{settings.NATS_HOST}:{settings.NATS_PORT}"], - user=settings.NATS_USER, - password=settings.NATS_PASSWORD, + servers=[f"nats://{host or settings.NATS_HOST}:{port or settings.NATS_PORT}"], + user=user or settings.NATS_USER, + password=password or settings.NATS_PASSWORD, ) NatsClient._nc = nc NatsClient._js = nc.jetstream() # type: ignore @@ -45,15 +56,16 @@ async def close() -> None: @staticmethod - async def publish(subject: NatsSubjects, message: bytes) -> None: + async def publish(subject: NatsSubjects | str, message: bytes) -> None: if NatsClient._nc is None: await NatsClient.connect() nc = NatsClient._nc assert nc is not None - await nc.publish(subject.value, message) + subject_name = subject.value if isinstance(subject, NatsSubjects) else subject + await nc.publish(subject_name, message) @staticmethod - async def subscribe(subject: NatsSubjects, callback: Callable[[Any], Any]) -> None: + async def subscribe(subject: NatsSubjects | str, callback: Callable[[Any], Any]) -> None: if NatsClient._nc is None: await NatsClient.connect() nc = NatsClient._nc @@ -61,7 +73,8 @@ async def subscribe(subject: NatsSubjects, callback: Callable[[Any], Any]) -> No async def _wrapper(msg: Msg) -> None: await callback(msg.data) - await nc.subscribe(subject.value, cb=_wrapper) # type: ignore + subject_name = subject.value if isinstance(subject, NatsSubjects) else subject + await nc.subscribe(subject_name, cb=_wrapper) # type: ignore @staticmethod @@ -70,7 +83,8 @@ async def js_publish(subject: NatsSubjects, message: bytes, stream_name: str) -> await NatsClient.connect() js = NatsClient._js assert js is not None - await js.publish(subject.value, message, stream=stream_name) + subject_name = subject.value if isinstance(subject, NatsSubjects) else subject # type: ignore + await js.publish(subject_name, message, stream=stream_name) @staticmethod async def js_subscribe( @@ -88,8 +102,9 @@ async def _wrapper(msg: Msg) -> None: await msg.ack() js = NatsClient._js assert js is not None + subject_name = subject.value await js.subscribe( - subject=subject.value, + subject=subject_name, stream=stream_name, durable=durable_name, cb=_wrapper, diff --git a/app/infra/redis.py b/app/infra/redis.py index 66f9da1..0ce5e68 100644 --- a/app/infra/redis.py +++ b/app/infra/redis.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import cast, ClassVar from redis.asyncio import Redis @@ -6,19 +6,32 @@ class RedisClient: - client: Redis - _instance = None + _client: Redis + _instance: ClassVar["RedisClient | None"] = None - def __new__(cls, *args: Any, **kwargs: Any) -> "RedisClient": + def __init__(self, host: str, port: int, password: str) -> None: + self._client = Redis.from_url( # type: ignore + f"redis://{host}:{port}", + password=password, + decode_responses=True, + ) + + + @classmethod + def init(cls, host: str, port: int, password: str) -> "RedisClient": + if cls._instance is not None: + raise RuntimeError("RedisClient already initialized") + + cls._instance = cls(host, port, password) + return cls._instance + + @classmethod + def get_instance(cls) -> "RedisClient": if cls._instance is None: - cls._instance = super().__new__(cls) + raise RuntimeError("RedisClient not initialized") + return cls._instance - def __init__(self, host: str, port: int, password: str) -> None: - if not hasattr(self, "client"): - self.client = Redis.from_url( # type: ignore - f"redis://{host}:{port}", password=password, decode_responses=True - ) async def set( self, @@ -27,25 +40,37 @@ async def set( expire: int | None = None, nx: bool = False, ) -> bool: - return await self.client.set(key, value, ex=expire, nx=nx) + result = await self._client.set(key, value, ex=expire, nx=nx) + return bool(result) async def get(self, key: RedisKey | str) -> str | None: - return await self.client.get(key) + return await self._client.get(key) async def delete(self, key: RedisKey | str) -> int: - return await self.client.delete(key) + result = await self._client.delete(key) + return int(cast(int, result)) async def exists(self, key: RedisKey | str) -> bool: - return await self.client.exists(key) > 0 + result = await self._client.exists(key) + return int(cast(int, result)) > 0 async def expire(self, key: RedisKey | str, seconds: int) -> bool: - return await self.client.expire(key, seconds) + result = await self._client.expire(key, seconds) + return int(cast(int, result)) == 1 + + + async def sadd(self, key: RedisKey | str, *values: str) -> int: + result = self._client.sadd(key, *values) + return int(cast(int, result)) + + async def sismember(self, key: RedisKey | str, value: str) -> bool: + result = self._client.sismember(key, value) + return int(cast(int, result)) == 1 + + async def srem(self, key: RedisKey | str, *values: str) -> int: + result = self._client.srem(key, *values) + return int(cast(int, result)) - @classmethod - def get_instance(cls) -> "RedisClient": - if cls._instance is None: - raise RuntimeError("RedisClient not initialized") - return cls._instance async def close(self) -> None: - await self.client.close() + await self._client.close() diff --git a/app/main.py b/app/main.py index 41b4f8d..26c4965 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,6 @@ from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint - from app.core.config import settings from app.infra.minio import init_minio_client from app.infra.nats import NatsClient diff --git a/app/router/mobile/__init__.py b/app/router/mobile/__init__.py index b9be1e0..aa3c807 100644 --- a/app/router/mobile/__init__.py +++ b/app/router/mobile/__init__.py @@ -2,11 +2,12 @@ from app.router.mobile.auth import router as mobile_auth_router from app.router.mobile.enrollement import router as onboarding_router from app.router.mobile.event import router as event_router +from app.router.mobile.notifications import router as mobile_notifications_router -router = APIRouter(prefix="/user",tags=["user"]) +router = APIRouter(prefix="/user", tags=["user"]) router.add_api_route router.include_router(mobile_auth_router) router.include_router(onboarding_router) router.include_router(event_router) - +router.include_router(mobile_notifications_router) diff --git a/app/router/mobile/audit.py b/app/router/mobile/audit.py new file mode 100644 index 0000000..fe3226a --- /dev/null +++ b/app/router/mobile/audit.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.container import Container, get_container +from app.core.constant import AuditEventType +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.schema.response.mobile.audit import AuditEventListResponse, AuditEventSchema + +router = APIRouter(prefix="/audits", tags=["audits"]) + + +@router.get("", response_model=AuditEventListResponse) +async def list_audits( + event_type: AuditEventType | None = Query(None), + user_id: UUID | None = Query(None), + created_from: datetime | None = Query(None, alias="from"), + created_to: datetime | None = Query(None, alias="to"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + container: Container = Depends(get_container), + _: MobileUserSchema = Depends(get_current_mobile_user), +) -> AuditEventListResponse: + events = await container.audit_service.list_audit_events( + event_type=event_type, + user_id=user_id, + created_from=created_from, + created_to=created_to, + limit=limit, + offset=offset, + ) + return AuditEventListResponse( + items=[ + AuditEventSchema.from_model(audit_event, actor=actor) + for audit_event, actor in events + ] + ) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 1784300..52e34a1 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -7,7 +7,12 @@ from app.core.exceptions import AppException from app.deps.token_auth import MobileUserSchema, get_current_mobile_user -from app.schema.request.mobile.auth import MobileAuthRequest, RefreshTokenRequest +from app.schema.request.mobile.auth import ( + MobileAuthRequest, + RefreshTokenRequest, + UpdateDeviceTokenRequest, + InactivateDeviceRequest, +) from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema router = APIRouter(prefix="/auth") @@ -58,6 +63,37 @@ async def revoke_device( return {"message": "Device revoked successfully"} +@router.post("/devices/token") +async def update_device_token( + req: UpdateDeviceTokenRequest, + container: Container = Depends(get_container), + current_user: MobileUserSchema = Depends(get_current_mobile_user), +) -> dict[str, str]: + + await container.device_service.update_device_push_token( + device_id=req.device_id, + user_id=current_user.user_id, + push_token=req.push_token, + ) + + return {"message": "Device token updated"} + + +@router.post("/devices/inactivate") +async def inactivate_device( + req: InactivateDeviceRequest, + container: Container = Depends(get_container), + current_user: MobileUserSchema = Depends(get_current_mobile_user), +) -> dict[str, str]: + + await container.device_service.inactivate_device( + device_id=req.device_id, + user_id=current_user.user_id, + ) + + return {"message": "Device marked as inactive"} + + @router.get("/me", response_model=MeResponse) async def get_me( current_user: MobileUserSchema = Depends(get_current_mobile_user), diff --git a/app/router/mobile/notifications.py b/app/router/mobile/notifications.py new file mode 100644 index 0000000..f8d4a56 --- /dev/null +++ b/app/router/mobile/notifications.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends + +from app.container import Container, get_container +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.schema.request.mobile.notifications import MarkUserNotificationsReadRequest +from app.schema.response.mobile.notifications import UserNotificationListResponse + + +router = APIRouter(prefix="/notifications") + + +@router.get("", response_model=UserNotificationListResponse) +async def get_all_notifications( + container: Container = Depends(get_container), + current_user: MobileUserSchema = Depends(get_current_mobile_user), +) -> UserNotificationListResponse: + notifications = await container.user_notifications_service.get_all_notifications( + user_id=current_user.user_id, + ) + return UserNotificationListResponse.from_models(notifications) + + +@router.post("/read", response_model=UserNotificationListResponse) +async def mark_as_read( + req: MarkUserNotificationsReadRequest, + container: Container = Depends(get_container), + current_user: MobileUserSchema = Depends(get_current_mobile_user), +) -> UserNotificationListResponse: + notifications = await container.user_notifications_service.mark_notifications_as_read( + notification_ids=req.notification_ids, + user_id=current_user.user_id, + ) + return UserNotificationListResponse.from_models(notifications) diff --git a/app/router/web/__init__.py b/app/router/web/__init__.py index 396add6..9b1e12e 100644 --- a/app/router/web/__init__.py +++ b/app/router/web/__init__.py @@ -2,8 +2,9 @@ from app.router.web.staff_users import router as staff_users_router from app.router.web.event import router as event_router from app.router.web.auth import router as auth_routes - +from app.router.web.audit import router as audit_router router = APIRouter(prefix="/admin", tags=["admin"]) router.include_router(staff_users_router) router.include_router(event_router) router.include_router(auth_routes) +router.include_router(audit_router) diff --git a/app/router/web/audit.py b/app/router/web/audit.py new file mode 100644 index 0000000..2256756 --- /dev/null +++ b/app/router/web/audit.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.container import Container, get_container +from app.core.constant import AuditEventType +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.schema.response.web.audit import AuditEventListResponse, AuditEventSchema + +router = APIRouter(prefix="/audits", tags=["audits"]) + + +@router.get("", response_model=AuditEventListResponse) +async def list_audits( + event_type: AuditEventType | None = Query(None), + user_id: UUID | None = Query(None), + created_from: datetime | None = Query(None, alias="from"), + created_to: datetime | None = Query(None, alias="to"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + container: Container = Depends(get_container), + _: MobileUserSchema = Depends(get_current_mobile_user), +) -> AuditEventListResponse: + events = await container.audit_service.list_audit_events( + event_type=event_type, + user_id=user_id, + created_from=created_from, + created_to=created_to, + limit=limit, + offset=offset, + ) + return AuditEventListResponse( + items=[ + AuditEventSchema.from_model(audit_event, actor=actor) + for audit_event, actor in events + ] + ) diff --git a/app/schema/notification.py b/app/schema/notification.py new file mode 100644 index 0000000..574a856 --- /dev/null +++ b/app/schema/notification.py @@ -0,0 +1,24 @@ +from enum import Enum +from pydantic import BaseModel, ConfigDict, Field + + +class NotificationPriority(str, Enum): + HIGH = "high" + NORMAL = "normal" + LOW = "low" + + +class UnifiedNotification(BaseModel): + title: str + body: str + data: dict[str, str] = Field(default_factory=dict) + tokens: list[str] + priority: NotificationPriority = NotificationPriority.NORMAL + + model_config = ConfigDict(extra="forbid") + +PRIORITY_ORDER: tuple[NotificationPriority, ...] = ( + NotificationPriority.HIGH, + NotificationPriority.NORMAL, + NotificationPriority.LOW, +) diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index f83ffa8..fcbf1c5 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -16,3 +16,11 @@ class MobileAuthRequest(BaseModel): class RefreshTokenRequest(BaseModel): refresh_token: str + +class UpdateDeviceTokenRequest(BaseModel): + device_id: UUID + push_token: str + + +class InactivateDeviceRequest(BaseModel): + device_id: UUID diff --git a/app/schema/request/mobile/notifications.py b/app/schema/request/mobile/notifications.py new file mode 100644 index 0000000..f6a94dc --- /dev/null +++ b/app/schema/request/mobile/notifications.py @@ -0,0 +1,9 @@ +from uuid import UUID + +from pydantic import BaseModel, Field + + +class MarkUserNotificationsReadRequest(BaseModel): + notification_ids: list[UUID] = Field( + ..., min_length=1, max_length=100 + ) diff --git a/app/schema/response/mobile/audit.py b/app/schema/response/mobile/audit.py new file mode 100644 index 0000000..0119653 --- /dev/null +++ b/app/schema/response/mobile/audit.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from db.generated.models import AuditEvent, User +from app.core.constant import AuditEventType +from app.schema.response.mobile.auth import UserSchema + + +class AuditActorSchema(UserSchema): + display_name: str | None + + @classmethod + def from_user(cls, user: User) -> "AuditActorSchema": + return cls( + id=user.id, + email=user.email, + display_name=user.display_name, + ) + + +class AuditEventSchema(BaseModel): + id: UUID + event_type: AuditEventType + metadata: dict[str, Any] | None + created_at: datetime + actor: AuditActorSchema | None + + @classmethod + def from_model( + cls, + event: AuditEvent, + actor: User | None, + ) -> "AuditEventSchema": + return cls( + id=event.id, + event_type=AuditEventType(event.event_type), + metadata=event.metadata, + created_at=event.created_at, + actor=AuditActorSchema.from_user(actor) if actor else None, + ) + + +class AuditEventListResponse(BaseModel): + items: list[AuditEventSchema] diff --git a/app/schema/response/mobile/notifications.py b/app/schema/response/mobile/notifications.py new file mode 100644 index 0000000..d347248 --- /dev/null +++ b/app/schema/response/mobile/notifications.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from db.generated.models import Notification + + +class UserNotificationSchema(BaseModel): + id: UUID + type: str + payload: dict[str, Any] + read_at: datetime | None + created_at: datetime + + @classmethod + def from_model(cls, notification: Notification) -> "UserNotificationSchema": + return cls( + id=notification.id, + type=notification.type, + payload=notification.payload, + read_at=notification.read_at, + created_at=notification.created_at, + ) + + +class UserNotificationListResponse(BaseModel): + items: list[UserNotificationSchema] + + @classmethod + def from_models( + cls, + notifications: list[Notification], + ) -> "UserNotificationListResponse": + return cls(items=[UserNotificationSchema.from_model(item) for item in notifications]) diff --git a/app/schema/response/web/audit.py b/app/schema/response/web/audit.py new file mode 100644 index 0000000..0119653 --- /dev/null +++ b/app/schema/response/web/audit.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + +from db.generated.models import AuditEvent, User +from app.core.constant import AuditEventType +from app.schema.response.mobile.auth import UserSchema + + +class AuditActorSchema(UserSchema): + display_name: str | None + + @classmethod + def from_user(cls, user: User) -> "AuditActorSchema": + return cls( + id=user.id, + email=user.email, + display_name=user.display_name, + ) + + +class AuditEventSchema(BaseModel): + id: UUID + event_type: AuditEventType + metadata: dict[str, Any] | None + created_at: datetime + actor: AuditActorSchema | None + + @classmethod + def from_model( + cls, + event: AuditEvent, + actor: User | None, + ) -> "AuditEventSchema": + return cls( + id=event.id, + event_type=AuditEventType(event.event_type), + metadata=event.metadata, + created_at=event.created_at, + actor=AuditActorSchema.from_user(actor) if actor else None, + ) + + +class AuditEventListResponse(BaseModel): + items: list[AuditEventSchema] diff --git a/app/service/audit.py b/app/service/audit.py new file mode 100644 index 0000000..24a4b55 --- /dev/null +++ b/app/service/audit.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID + +from app.core.constant import AuditEventType +from app.core.exceptions import AppException +from db.generated import audit as audit_queries +from db.generated import user as user_queries +from db.generated.models import AuditEvent, User +from app.worker.audit.schema.audit import AuditEventMessage +from app.infra.nats import NatsClient, NatsSubjects + + +class AuditService: + def __init__( + self, + audit_querier: audit_queries.AsyncQuerier, + user_querier: user_queries.AsyncQuerier, + ) -> None: + self.audit_querier = audit_querier + self.user_querier = user_querier + + _DEFAULT_CREATED_FROM = datetime(1970, 1, 1, tzinfo=timezone.utc) + _DEFAULT_CREATED_TO = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + + async def record_event( + self, + *, + event_type: AuditEventType, + user_id: UUID | None = None, + metadata: dict[str, Any] | None = None, + ) -> AuditEvent: + audit = await self.audit_querier.create_audit_event( + event_type=event_type.value, + user_id=user_id, + metadata=metadata, + ) + if audit is None: + raise AppException.internal_error("Failed to persist audit event") + return audit + + async def create_record( + self, + *, + event_type: AuditEventType, + user_id: UUID | None = None, + metadata: dict[str, Any] | None = None, + description: str | None = None, + ) -> None: + message = AuditEventMessage( + event_type=event_type, + user_id=user_id, + metadata=metadata, + description=description, + ).model_dump_json() + await NatsClient.publish(NatsSubjects.AUDIT_EVENT, message.encode("utf-8")) + + async def list_audit_events( + self, + *, + event_type: AuditEventType | None = None, + user_id: UUID | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[tuple[AuditEvent, User | None]]: + params = audit_queries.ListAuditEventsParams( + event_type.value if event_type else None, + user_id, + created_from or self._DEFAULT_CREATED_FROM, + created_to or self._DEFAULT_CREATED_TO, + limit, + offset, + ) + events: list[AuditEvent] = [] + async for event in self.audit_querier.list_audit_events(arg=params): + events.append(event) + + user_ids = {event.user_id for event in events if event.user_id is not None} + actors: dict[UUID, User] = {} + for user_id in user_ids: + user = await self.user_querier.get_user_by_id(id=user_id) + if user: + actors[user_id] = user + return [ + ( + event, + actors.get(event.user_id) if event.user_id is not None else None, + ) + for event in events + ] diff --git a/app/service/device.py b/app/service/device.py index 928dfb5..18c2928 100644 --- a/app/service/device.py +++ b/app/service/device.py @@ -35,6 +35,19 @@ async def create_device( except Exception as e : raise DBException.handle(e) + async def activate_device( + self: "DeviceService", + device_id: uuid.UUID, + user_id: uuid.UUID, + ) -> None: + try: + await self.device_querier.activate_device( + id=device_id, + user_id=user_id, + ) + except Exception as e: + raise DBException.handle(e) + async def revoke_device( self: "DeviceService", device_id: uuid.UUID, @@ -49,6 +62,40 @@ async def revoke_device( except Exception as e : raise DBException.handle(e) + async def update_device_push_token( + self: "DeviceService", + device_id: uuid.UUID, + user_id: uuid.UUID, + push_token: str, + ) -> UserDevice: + try: + device = await self.device_querier.update_device_push_token( + id=device_id, + push_token=push_token, + user_id=user_id, + ) + if device is None: + raise AppException.not_found("Device not found") + return device + except Exception as e: + raise DBException.handle(e) + + async def inactivate_device( + self: "DeviceService", + device_id: uuid.UUID, + user_id: uuid.UUID, + ) -> None: + try: + device = await self.device_querier.get_device__by_id(id=device_id) + if device is None or device.user_id != user_id: + raise AppException.not_found("Device not found") + await self.device_querier.deactivate_device( + id=device_id, + user_id=user_id, + ) + except Exception as e: + raise DBException.handle(e) + async def get_all_devices(self: "DeviceService", user_id: uuid.UUID) -> tuple[list[UserDevice], int]: devices: list[UserDevice] = [] @@ -81,4 +128,3 @@ async def count_devices(self: "DeviceService", user_id: uuid.UUID) -> int: except Exception as e : raise DBExceptionImpl.handle(e) - diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index f71c906..5b01d7c 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -81,7 +81,7 @@ def embed(self, image: np.ndarray, bboxes: Sequence[BBox]) -> list[float]: if not faces: raise ValueError("No faces detected by the model") - x1, y1, x2, y2 = bboxes[0] + x1, y1, x2, y2 = bboxes[0] # type: ignore target_cx = (x1 + x2) / 2 target_cy = (y1 + y2) / 2 diff --git a/app/service/user_notification.py b/app/service/user_notification.py new file mode 100644 index 0000000..1d73971 --- /dev/null +++ b/app/service/user_notification.py @@ -0,0 +1,72 @@ +from typing import Any +import uuid + +from app.core.exceptions import AppException +from app.schema.notification import UnifiedNotification +from app.worker.notification.notification_queue import NotificationQueue +from db.generated import notifications as notification_queries +from db.generated.models import Notification + + +class UserNotificationService: + def __init__( + self, + notification_querier: notification_queries.AsyncQuerier, + notification_queue: NotificationQueue, + ) -> None: + self.notification_querier = notification_querier + self._notification_queue = notification_queue + + async def create_notification( + self, + *, + user_id: uuid.UUID, + type: str, + payload: dict[str, Any], + notification: UnifiedNotification | None = None, + ) -> Notification: + notification_record = await self.notification_querier.create_notification( + user_id=user_id, + type=type, + payload=payload, + ) + if notification_record is None: + raise AppException.internal_error("Failed to create user notification") + + if notification is not None: + await self._notification_queue.enqueue_notification(notification) + + return notification_record + + async def get_all_notifications( + self, + *, + user_id: uuid.UUID, + ) -> list[Notification]: + notifications: list[Notification] = [] + async for notification in self.notification_querier.list_notifications_by_user_id( + user_id=user_id + ): + notifications.append(notification) + return notifications + + async def mark_notifications_as_read( + self, + *, + notification_ids: list[uuid.UUID], + user_id: uuid.UUID, + ) -> list[Notification]: + notifications: list[Notification] = [] + seen_notification_ids: set[uuid.UUID] = set() + for notification_id in notification_ids: + if notification_id in seen_notification_ids: + continue + seen_notification_ids.add(notification_id) + notification = await self.notification_querier.mark_notification_as_read( + id=notification_id, + user_id=user_id, + ) + if notification is None: + raise AppException.not_found("Notification not found or already read") + notifications.append(notification) + return notifications diff --git a/app/service/users.py b/app/service/users.py index ecfaf91..c706159 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -19,7 +19,7 @@ from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries -from db.generated.models import User +from db.generated.models import User, UserDevice from app.core.logger import logger from app.service.face_embedding import FaceImagePayload, FaceEmbeddingService @@ -43,6 +43,37 @@ def __init__( self.session_querier = session_querier self.face_embedding_service = face_embedding_service + async def _ensure_device_for_login( + self, + user_id: uuid.UUID, + req: MobileAuthRequest, + ) -> UserDevice: + existing_device = await self.device_querier.get_device__by_id(id=req.device_id) + + if existing_device: + if existing_device.user_id != user_id: + raise AppException.forbidden("Device already registered to another user") + if existing_device.is_invalid_token: + raise AppException.forbidden( + "Device push token is invalid. Update the token before logging in." + ) + if not existing_device.is_active: + await self.device_querier.activate_device(id=req.device_id, user_id=user_id) + return existing_device + + device = await self.device_querier.create_device( + arg=device_queries.CreateDeviceParams( + column_1=req.device_id, + user_id=user_id, + device_name=req.device_name, + device_type=req.device_type, + totp_secret=None, + ) + ) + if not device: + raise AppException.internal_error("Failed to create device") + return device + async def mobile_register_login( self, redis: RedisClient, @@ -86,20 +117,7 @@ async def mobile_register_login( device_id = req.device_id expires_at = datetime.now(timezone.utc) + timedelta(days=7) - device = await self.device_querier.create_device( - arg=device_queries.CreateDeviceParams( - column_1=device_id, - user_id=user_id, - device_name=req.device_name, - device_type=req.device_type, - totp_secret=None, - - ) - - ) - - if not device: - raise AppException.internal_error("Failed to create device") + await self._ensure_device_for_login(user_id, req) session = await self.session_querier.upsert_session( user_id=user_id, diff --git a/app/worker/audit/__init__.py b/app/worker/audit/__init__.py new file mode 100644 index 0000000..dcd57fc --- /dev/null +++ b/app/worker/audit/__init__.py @@ -0,0 +1,6 @@ +"""Audit worker package exports.""" +from __future__ import annotations + +from .main import main # noqa: F401 + +__all__ = ["main"] diff --git a/app/worker/audit/main.py b/app/worker/audit/main.py new file mode 100644 index 0000000..95ceae8 --- /dev/null +++ b/app/worker/audit/main.py @@ -0,0 +1,101 @@ +import asyncio +import json +from typing import Any +import sqlalchemy.ext.asyncio +from pydantic import ValidationError +from app.core.constant import AUDIT_EVENT_SUBJECT +from app.core.logger import logger +from app.infra.database import engine +from app.infra.nats import NatsClient, NatsSubjects +from app.service.audit import AuditService +from db.generated import audit as audit_queries +from db.generated import user as user_queries +from app.worker.audit.schema.audit import AuditEventMessage + + +async def init_worker() -> None: + logger.info("Audit worker starting with metadata limit ") + + +class AuditDeliveryWorker: + def __init__(self) -> None: + self._conn: sqlalchemy.ext.asyncio.AsyncConnection | None = None + self._audit_service: AuditService | None = None + + async def start(self) -> None: + if self._conn is not None: + return + self._conn = await engine.connect() + self._audit_service = AuditService( + audit_queries.AsyncQuerier(self._conn), + user_queries.AsyncQuerier(self._conn), + ) + + async def stop(self) -> None: + if self._conn is not None: + await self._conn.close() + self._conn = None + self._audit_service = None + + async def persist(self, payload: AuditEventMessage) -> None: + if self._audit_service is None: + logger.warning("Audit service is unavailable for %s", payload.event_type) + return + await self._audit_service.record_event( + event_type=payload.event_type, + user_id=payload.user_id, + metadata=payload.metadata, + ) + logger.info("Persisted audit %s for %s", payload.event_type, payload.user_id) + + +def _parse_payload(raw_data: bytes) -> dict[str, Any] | None: + try: + parsed = json.loads(raw_data.decode("utf-8")) + if not isinstance(parsed, dict): + logger.warning("Audit payload must be an object, got %s", type(parsed)) # type: ignore + return None + return parsed # type: ignore + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + logger.error("Cannot parse audit payload: %s", exc) + return None + + +async def _handle_event(worker: AuditDeliveryWorker, raw_data: bytes) -> None: + parsed = _parse_payload(raw_data) + if parsed is None: + return + try: + payload = AuditEventMessage.model_validate(parsed) + except ValidationError as exc: + logger.warning("Audit payload validation failed: %s", exc) + return + try: + await worker.persist(payload) + except Exception: + logger.exception("Failed to persist audit for %s", payload.event_type) + + +async def listen_nats_event(worker: AuditDeliveryWorker) -> None: + await NatsClient.subscribe( + NatsSubjects.AUDIT_EVENT, + lambda data: _handle_event(worker, data), + ) + logger.info("Listening for audit events on %s", AUDIT_EVENT_SUBJECT) + + +async def main() -> None: + await init_worker() + worker = AuditDeliveryWorker() + await worker.start() + await NatsClient.connect() + try: + await listen_nats_event(worker) + await asyncio.Event().wait() + finally: + await worker.stop() + await NatsClient.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/worker/audit/schema/audit.py b/app/worker/audit/schema/audit.py new file mode 100644 index 0000000..e0f1c9b --- /dev/null +++ b/app/worker/audit/schema/audit.py @@ -0,0 +1,11 @@ +from typing import Any +from uuid import UUID +from pydantic import BaseModel +from app.core.constant import AuditEventType + + +class AuditEventMessage(BaseModel): + event_type: AuditEventType + user_id: UUID | None = None + metadata: dict[str, Any] | None = None + description: str | None = None diff --git a/app/worker/audit/settings.py b/app/worker/audit/settings.py new file mode 100644 index 0000000..6d081c8 --- /dev/null +++ b/app/worker/audit/settings.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pydantic_settings import BaseSettings + + +class AuditWorkerSettings(BaseSettings): + + class Config: + env_prefix = "AUDIT_" + + +settings = AuditWorkerSettings() # type: ignore diff --git a/app/worker/notification/__init__.py b/app/worker/notification/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/notification/firebase.py b/app/worker/notification/firebase.py new file mode 100644 index 0000000..ffc2360 --- /dev/null +++ b/app/worker/notification/firebase.py @@ -0,0 +1,101 @@ +from __future__ import annotations +from typing import cast + +# pyright: ignore[reportMissingTypeStubs] +import firebase_admin # type: ignore[import-untyped] +# pyright: ignore[reportMissingTypeStubs] +from firebase_admin import credentials, messaging # type: ignore[import-untyped] + +from app.core.config import settings +from app.core.logger import logger +from app.schema.notification import UnifiedNotification + + +INVALID_TOKEN_CODES = { + "messaging/registration-token-not-registered", + "messaging/invalid-registration-token", +} + + +class _SendResponse: + success: bool + exception: Exception | None + + +class _BatchResponse: + responses: list[_SendResponse] +class NotificationDeliveryError(Exception): + def __init__( + self, + *, + failed_tokens: list[str], + invalid_tokens: list[str], + ) -> None: + self.failed_tokens = failed_tokens + self.invalid_tokens = invalid_tokens + super().__init__( + f"Failed tokens: {failed_tokens}, invalid tokens: {invalid_tokens}" + ) + + +def init_firebase_app(credentials_path: str | None = None) -> None: + if firebase_admin._apps: # type: ignore + return + if credentials_path is None: + credentials_path = settings.FIREBASE_CREDENTIALS_PATH + if credentials_path: + cred = credentials.Certificate(credentials_path) + firebase_admin.initialize_app(cred) # type: ignore + logger.info("Firebase initialized with credentials from %s", credentials_path) + return + firebase_admin.initialize_app() # type: ignore + logger.info("Firebase initialized with default credentials") + + +def _classify_token_failure(error: Exception) -> bool: + code = getattr(error, "code", None) + + if code in INVALID_TOKEN_CODES: + return True + + name = error.__class__.__name__ + return name in {"UnregisteredError", "InvalidArgumentError"} + + +def send_notification(notification: UnifiedNotification) -> None: + if not notification.tokens: + logger.debug("Skipping notification without tokens: %s", notification) + return + multicast = messaging.MulticastMessage( + tokens=notification.tokens, + notification=messaging.Notification( + title=notification.title, + body=notification.body, + ), + data=notification.data or None, + ) + response = cast( + _BatchResponse, + messaging.send_multicast(multicast) # type: ignore +) + + failed_tokens: list[str] = [] + invalid_tokens: list[str] = [] + + for token, result in zip(notification.tokens, response.responses): + if result.success or result.exception is None: + continue + if _classify_token_failure(result.exception): + invalid_tokens.append(token) + else: + failed_tokens.append(token) + + if failed_tokens or invalid_tokens: + raise NotificationDeliveryError( + failed_tokens=failed_tokens, + invalid_tokens=invalid_tokens, + ) + + logger.info( + "Notification delivered to %d tokens", len(notification.tokens) + ) diff --git a/app/worker/notification/invalid_tokens.py b/app/worker/notification/invalid_tokens.py new file mode 100644 index 0000000..02e24c9 --- /dev/null +++ b/app/worker/notification/invalid_tokens.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Iterable, Sequence + +from db.generated import devices as device_queries + +from app.core.constant import RedisKey +from app.core.logger import logger +from app.infra.redis import RedisClient +from app.worker.notification.settings import NotifSetting + + +class InvalidTokenStore: + def __init__(self, redis: RedisClient) -> None: + self._redis = redis + + async def mark_invalid(self, tokens: Iterable[str]) -> None: + normalized: list[str] = [t for t in tokens if t] + + if not normalized: + return + + await self._redis.sadd(RedisKey.INVALID_TOKEN_SET_KEY, *normalized) + await self._redis.expire(RedisKey.INVALID_TOKEN_SET_KEY, NotifSetting.TTL_SECONDS) + + logger.warning("Marked %d tokens for cleanup", len(normalized)) + + async def is_invalid(self, token: str) -> bool: + if not token: + return False + + return await self._redis.sismember( + RedisKey.INVALID_TOKEN_SET_KEY, token + ) + + async def remove(self, tokens: Sequence[str]) -> None: + if not tokens: + return + + await self._redis.srem( + RedisKey.INVALID_TOKEN_SET_KEY, *tokens + ) + + +class DeviceInvalidationStore: + def __init__(self, device_querier: device_queries.AsyncQuerier) -> None: + self._device_querier = device_querier + + async def mark_invalid(self, tokens: Iterable[str]) -> None: + normalized: list[str] = [t for t in tokens if t] + + if not normalized: + return + + failed: list[str] = [] + for token in normalized: + try: + await self._device_querier.mark_device_token_invalid(push_token=token) + except Exception: + failed.append(token) + logger.exception("Failed to flag device for invalid token %s", token) + + marked = len(normalized) - len(failed) + if marked: + logger.warning("Flagged %d devices as invalid", marked) diff --git a/app/worker/notification/main.py b/app/worker/notification/main.py new file mode 100644 index 0000000..3f4711d --- /dev/null +++ b/app/worker/notification/main.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +from typing import Sequence + +from db.generated import devices as device_queries + +from app.core.logger import logger +from app.worker.notification.firebase import ( + NotificationDeliveryError, + init_firebase_app, + send_notification, +) +from app.worker.notification.invalid_tokens import ( + DeviceInvalidationStore, + InvalidTokenStore, +) +from app.worker.notification.notification_queue import NotificationQueue, NotificationQueueEntry +from app.worker.notification.rate_limiter import RateLimiter +from app.worker.notification.settings import NotifSetting +from app.infra.database import engine +from app.infra.redis import RedisClient +from app.infra.nats import NatsClient + + +async def process_entry( + entry: NotificationQueueEntry, + queue: NotificationQueue, + invalid_tokens: InvalidTokenStore, + invalid_devices: DeviceInvalidationStore, +) -> None: + try: + valid_tokens = [ + t + for t in entry.notification.tokens + if not await invalid_tokens.is_invalid(t) + ] + + if not valid_tokens: + logger.info("All tokens are invalid, skipping notification") + return + + notification = entry.notification.model_copy(update={"tokens": valid_tokens}) + await asyncio.to_thread(send_notification, notification) + + except NotificationDeliveryError as e: + if e.invalid_tokens: + await invalid_tokens.mark_invalid(e.invalid_tokens) + await invalid_devices.mark_invalid(e.invalid_tokens) + if e.failed_tokens: + await retry(entry, queue, tokens=e.failed_tokens) + + except Exception: + logger.exception("Unexpected error, retrying") + await retry(entry, queue) + + + +async def retry( + entry: NotificationQueueEntry, + queue: NotificationQueue, + tokens: Sequence[str] | None = None, +) -> None: + attempts = entry.attempts + 1 + + if attempts >= NotifSetting.MAX_SEND_ATTEMPTS: + logger.warning("Dropping notification after %d attempts", attempts) + return + + notification = entry.notification + + if tokens is not None: + notification = notification.model_copy(update={"tokens": list(tokens)}) + if not notification.tokens: + return + + delay = min(NotifSetting.BASE_RETRY_DELAY * (2 ** attempts), 60) + + await asyncio.sleep(delay) + await queue.enqueue_notification(notification, attempts=attempts) + + + +async def handle_message( + raw_payload: bytes | str, + queue: NotificationQueue, + invalid_tokens: InvalidTokenStore, + invalid_devices: DeviceInvalidationStore, +) -> None: + try: + if isinstance(raw_payload, bytes): + raw_payload = raw_payload.decode() + + entry = NotificationQueueEntry.model_validate_json(raw_payload) + + except Exception: + logger.exception("Invalid message payload") + return + + await process_entry(entry, queue, invalid_tokens, invalid_devices) + + + +async def run_worker( + queue: NotificationQueue, + invalid_tokens: InvalidTokenStore, + invalid_devices: DeviceInvalidationStore, +) -> None: + logger.info("Notification worker started") + + semaphore = asyncio.Semaphore(NotifSetting.CONCURRENCY) + rate_limiter = RateLimiter(NotifSetting.RATE_LIMIT, NotifSetting.RATE_PERIOD) + + async def wrapped_handler(msg: bytes | str) -> None: + async with semaphore: + await rate_limiter.acquire() + await handle_message(msg, queue, invalid_tokens, invalid_devices) + + for subject in queue.priority_subjects(): + await NatsClient.subscribe(subject, wrapped_handler) + + await asyncio.Event().wait() + + + +async def main() -> None: + init_firebase_app(NotifSetting.firebase_credentials_path) + + await NatsClient.connect( + host=NotifSetting.nats_host, + port=NotifSetting.nats_port, + user=NotifSetting.nats_user, + password=NotifSetting.nats_password, + ) + + redis = RedisClient( + host=NotifSetting.redis_host, + port=NotifSetting.redis_port, + password=NotifSetting.redis_password, + ) + + queue = NotificationQueue(settings=NotifSetting) + invalid_tokens = InvalidTokenStore(redis) + db_conn = await engine.connect() + device_querier = device_queries.AsyncQuerier(db_conn) + invalid_devices = DeviceInvalidationStore(device_querier) + + try: + await run_worker(queue, invalid_tokens, invalid_devices) + + finally: + await redis.close() + await db_conn.close() + logger.info("Worker shutdown") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/worker/notification/notification_queue.py b/app/worker/notification/notification_queue.py new file mode 100644 index 0000000..bf1589d --- /dev/null +++ b/app/worker/notification/notification_queue.py @@ -0,0 +1,34 @@ +from typing import Sequence +from pydantic import BaseModel, ConfigDict, Field +from app.infra.nats import NatsClient +from app.schema.notification import NotificationPriority, PRIORITY_ORDER, UnifiedNotification +from app.worker.notification.settings import NotificationWorkerSettings + + +class NotificationQueueEntry(BaseModel): + notification: UnifiedNotification + attempts: int = Field(default=0, ge=0) + + model_config = ConfigDict(extra="forbid") + + +class NotificationQueue: + def __init__(self, settings: NotificationWorkerSettings) -> None: + self._settings = settings + + async def enqueue_notification( + self, + notification: UnifiedNotification, + attempts: int = 0 + ) -> None: + entry = NotificationQueueEntry(notification=notification, attempts=attempts) + subject = self._settings.subject_for(entry.notification.priority) + payload = entry.model_dump_json().encode("utf-8") + await NatsClient.publish(subject, payload) + + @staticmethod + def priority_index(priority: NotificationPriority) -> int: + return PRIORITY_ORDER.index(priority) + + def priority_subjects(self) -> Sequence[str]: + return self._settings.priority_subjects() diff --git a/app/worker/notification/rate_limiter.py b/app/worker/notification/rate_limiter.py new file mode 100644 index 0000000..0828527 --- /dev/null +++ b/app/worker/notification/rate_limiter.py @@ -0,0 +1,26 @@ +import asyncio +import time + + +class RateLimiter: + def __init__(self, rate: int, per: float) -> None: + self._rate = rate + self._per = per + self._tokens: float = float(rate) + self._last = time.monotonic() + self._lock = asyncio.Lock() + + async def acquire(self) -> None: + async with self._lock: + now = time.monotonic() + elapsed = now - self._last + refill = elapsed * (self._rate / self._per) + self._tokens = min(self._rate, self._tokens + refill) + self._last = now + + if self._tokens < 1: + sleep_time = (1 - self._tokens) * (self._per / self._rate) + await asyncio.sleep(sleep_time) + self._tokens = 0 + else: + self._tokens -= 1 diff --git a/app/worker/notification/settings.py b/app/worker/notification/settings.py new file mode 100644 index 0000000..4d23dd7 --- /dev/null +++ b/app/worker/notification/settings.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Sequence + +from pydantic import Field +from pydantic_settings import BaseSettings + +from app.schema.notification import NotificationPriority, PRIORITY_ORDER + + +class NotificationWorkerSettings(BaseSettings): + subject_prefix: str = Field("notifications.delivery") + queue_group: str | None = Field(None) + redis_host: str = Field("localhost") + redis_port: int = Field(6379) + redis_password: str = Field("") + nats_host: str = Field("localhost") + nats_port: int = Field(4222) + nats_user: str = Field("") + nats_password: str = Field("") + firebase_credentials_path: str | None = Field(None) + MAX_SEND_ATTEMPTS = 5 + BASE_RETRY_DELAY = 2 + TTL_SECONDS = 30 * 24 * 3600 + CONCURRENCY = 10 + RATE_LIMIT = 50 + RATE_PERIOD = 1.0 + + class Config: + env_prefix = "NOTIFICATIONS_" + + def subject_for(self, priority: NotificationPriority) -> str: + return f"{self.subject_prefix}.{priority.value}" + + def priority_subjects(self) -> Sequence[str]: + return [self.subject_for(priority) for priority in PRIORITY_ORDER] + +NotifSetting = NotificationWorkerSettings() # type: ignore diff --git a/app/worker/storage_cleaner/main.py b/app/worker/storage_cleaner/main.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/storage_cleaner/settings.py b/app/worker/storage_cleaner/settings.py new file mode 100644 index 0000000..e69de29 diff --git a/db/__init__.py b/db/__init__.py new file mode 100644 index 0000000..05f2bf4 --- /dev/null +++ b/db/__init__.py @@ -0,0 +1,2 @@ +"""Database package placeholder used for tooling.""" + diff --git a/db/generated/audit.py b/db/generated/audit.py new file mode 100644 index 0000000..47bf3f4 --- /dev/null +++ b/db/generated/audit.py @@ -0,0 +1,83 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: audit.sql +import dataclasses +import datetime +from typing import Any, AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +CREATE_AUDIT_EVENT = """-- name: create_audit_event \\:one +INSERT INTO audit_events ( + event_type, + user_id, + metadata +) VALUES ( + :p1, :p2, :p3 +) +RETURNING id, event_type, user_id, metadata, created_at +""" + + +LIST_AUDIT_EVENTS = """-- name: list_audit_events \\:many +SELECT id, event_type, user_id, metadata, created_at +FROM audit_events +WHERE event_type = COALESCE(:p1, event_type) + AND user_id = COALESCE(:p2, user_id) + AND created_at >= COALESCE(:p3, created_at) + AND created_at <= COALESCE(:p4, created_at) +ORDER BY created_at DESC +LIMIT :p5 +OFFSET :p6 +""" + + +@dataclasses.dataclass() +class ListAuditEventsParams: + event_type: Any + user_id: Optional[uuid.UUID] + created_at: datetime.datetime + created_at_2: datetime.datetime + limit: int + offset: int + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def create_audit_event(self, *, event_type: Any, user_id: Optional[uuid.UUID], metadata: Optional[Any]) -> Optional[models.AuditEvent]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_AUDIT_EVENT), {"p1": event_type, "p2": user_id, "p3": metadata})).first() + if row is None: + return None + return models.AuditEvent( + id=row[0], + event_type=row[1], + user_id=row[2], + metadata=row[3], + created_at=row[4], + ) + + async def list_audit_events(self, arg: ListAuditEventsParams) -> AsyncIterator[models.AuditEvent]: + result = await self._conn.stream(sqlalchemy.text(LIST_AUDIT_EVENTS), { + "p1": arg.event_type, + "p2": arg.user_id, + "p3": arg.created_at, + "p4": arg.created_at_2, + "p5": arg.limit, + "p6": arg.offset, + }) + async for row in result: + yield models.AuditEvent( + id=row[0], + event_type=row[1], + user_id=row[2], + metadata=row[3], + created_at=row[4], + ) diff --git a/db/generated/devices.py b/db/generated/devices.py index 2df5e9b..2514ff9 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -12,6 +12,14 @@ from db.generated import models +ACTIVATE_DEVICE = """-- name: activate_device \\:exec +UPDATE user_devices +SET is_active = TRUE +WHERE id = :p1 +AND user_id = :p2 +""" + + COUNT__USER__DEVICES = """-- name: count__user__devices \\:one SELECT COUNT(*) FROM user_devices @@ -29,7 +37,7 @@ ) VALUES ( COALESCE(:p1, uuid_generate_v4()), :p2, :p3, :p4, :p5 ) -RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at +RETURNING id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at """ @@ -42,6 +50,14 @@ class CreateDeviceParams: totp_secret: Optional[str] +DEACTIVATE_DEVICE = """-- name: deactivate_device \\:exec +UPDATE user_devices +SET is_active = FALSE +WHERE id = :p1 +AND user_id = :p2 +""" + + ENABLE_DEVICE2_FA = """-- name: enable_device2_fa \\:exec UPDATE user_devices SET is_2fa_enabled = TRUE @@ -52,19 +68,28 @@ class CreateDeviceParams: GET_DEVICE__BY_ID = """-- name: get_device__by_id \\:one -SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at from user_devices +SELECT id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at from user_devices WHERE id =:p1 """ LIST_USER_DEVICES = """-- name: list_user_devices \\:many -SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at +SELECT id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at FROM user_devices WHERE user_id = :p1 ORDER BY last_active DESC """ +MARK_DEVICE_TOKEN_INVALID = """-- name: mark_device_token_invalid \\:exec +UPDATE user_devices +SET + is_invalid_token = TRUE, + is_active = FALSE +WHERE push_token = :p1 +""" + + REVOKE_DEVICE = """-- name: revoke_device \\:exec DELETE FROM user_devices WHERE id = :p1 @@ -79,10 +104,25 @@ class CreateDeviceParams: """ +UPDATE_DEVICE_PUSH_TOKEN = """-- name: update_device_push_token \\:one +UPDATE user_devices +SET + push_token = :p2, + is_active = TRUE, + is_invalid_token = FALSE +WHERE id = :p1 +AND user_id = :p3 +RETURNING id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at +""" + + class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn + async def activate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: + await self._conn.execute(sqlalchemy.text(ACTIVATE_DEVICE), {"p1": id, "p2": user_id}) + async def count__user__devices(self, *, user_id: uuid.UUID) -> Optional[int]: row = (await self._conn.execute(sqlalchemy.text(COUNT__USER__DEVICES), {"p1": user_id})).first() if row is None: @@ -104,12 +144,18 @@ async def create_device(self, arg: CreateDeviceParams) -> Optional[models.UserDe user_id=row[1], device_name=row[2], device_type=row[3], - totp_secret=row[4], - is_2fa_enabled=row[5], - last_active=row[6], - created_at=row[7], + push_token=row[4], + totp_secret=row[5], + is_active=row[6], + is_invalid_token=row[7], + is_2fa_enabled=row[8], + last_active=row[9], + created_at=row[10], ) + async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: + await self._conn.execute(sqlalchemy.text(DEACTIVATE_DEVICE), {"p1": id, "p2": user_id}) + async def enable_device2_fa(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(ENABLE_DEVICE2_FA), {"p1": id, "p2": user_id}) @@ -122,10 +168,13 @@ async def get_device__by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevic user_id=row[1], device_name=row[2], device_type=row[3], - totp_secret=row[4], - is_2fa_enabled=row[5], - last_active=row[6], - created_at=row[7], + push_token=row[4], + totp_secret=row[5], + is_active=row[6], + is_invalid_token=row[7], + is_2fa_enabled=row[8], + last_active=row[9], + created_at=row[10], ) async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models.UserDevice]: @@ -136,14 +185,38 @@ async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models user_id=row[1], device_name=row[2], device_type=row[3], - totp_secret=row[4], - is_2fa_enabled=row[5], - last_active=row[6], - created_at=row[7], + push_token=row[4], + totp_secret=row[5], + is_active=row[6], + is_invalid_token=row[7], + is_2fa_enabled=row[8], + last_active=row[9], + created_at=row[10], ) + async def mark_device_token_invalid(self, *, push_token: Optional[str]) -> None: + await self._conn.execute(sqlalchemy.text(MARK_DEVICE_TOKEN_INVALID), {"p1": push_token}) + async def revoke_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(REVOKE_DEVICE), {"p1": id, "p2": user_id}) async def update_device_last_active(self, *, id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(UPDATE_DEVICE_LAST_ACTIVE), {"p1": id}) + + async def update_device_push_token(self, *, id: uuid.UUID, push_token: Optional[str], user_id: uuid.UUID) -> Optional[models.UserDevice]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_DEVICE_PUSH_TOKEN), {"p1": id, "p2": push_token, "p3": user_id})).first() + if row is None: + return None + return models.UserDevice( + id=row[0], + user_id=row[1], + device_name=row[2], + device_type=row[3], + push_token=row[4], + totp_secret=row[5], + is_active=row[6], + is_invalid_token=row[7], + is_2fa_enabled=row[8], + last_active=row[9], + created_at=row[10], + ) diff --git a/db/generated/models.py b/db/generated/models.py index 28a9da1..db5740d 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -8,6 +8,15 @@ import uuid +class AuditEventType(str, enum.Enum): + USERSIGNUP = "user.signup" + USERLOGIN = "user.login" + USERLOGOUT = "user.logout" + UPLOAD_REQUESTCREATED = "upload_request.created" + UPLOAD_REQUESTAPPROVED = "upload_request.approved" + UPLOAD_REQUESTREJECTED = "upload_request.rejected" + + class EventStatus(str, enum.Enum): DRAFT = "draft" SCHEDULED = "scheduled" @@ -29,8 +38,8 @@ class ProcessingJobStatus(str, enum.Enum): class StaffRole(str, enum.Enum): ADMIN = "admin" - MULTI_TEAM_LEAD = "multi_team_lead" MULTI = "multi" + MULTI_TEAM_LEAD = "multi_team_lead" class UploadRequestStatus(str, enum.Enum): @@ -44,6 +53,15 @@ class AlembicVersion: version_num: str +@dataclasses.dataclass() +class AuditEvent: + id: uuid.UUID + event_type: Any + user_id: Optional[uuid.UUID] + metadata: Optional[Any] + created_at: datetime.datetime + + @dataclasses.dataclass() class Event: id: uuid.UUID @@ -212,7 +230,10 @@ class UserDevice: user_id: uuid.UUID device_name: Optional[str] device_type: Optional[str] + push_token: Optional[str] totp_secret: Optional[str] + is_active: bool + is_invalid_token: bool is_2fa_enabled: bool last_active: datetime.datetime created_at: datetime.datetime diff --git a/db/generated/notifications.py b/db/generated/notifications.py new file mode 100644 index 0000000..3166cd3 --- /dev/null +++ b/db/generated/notifications.py @@ -0,0 +1,84 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: notifications.sql +from typing import Any, AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +CREATE_NOTIFICATION = """-- name: create_notification \\:one +INSERT INTO notifications ( + user_id, + type, + payload +) VALUES ( + :p1, :p2, :p3 +) +RETURNING id, user_id, type, payload, read_at, created_at +""" + + +LIST_NOTIFICATIONS_BY_USER_ID = """-- name: list_notifications_by_user_id \\:many +SELECT id, user_id, type, payload, read_at, created_at +FROM notifications +WHERE user_id = :p1 +ORDER BY created_at DESC +""" + + +MARK_NOTIFICATION_AS_READ = """-- name: mark_notification_as_read \\:one +UPDATE notifications +SET read_at = NOW() +WHERE id = :p1 + AND user_id = :p2 + AND read_at IS NULL +RETURNING id, user_id, type, payload, read_at, created_at +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def create_notification(self, *, user_id: uuid.UUID, type: str, payload: Any) -> Optional[models.Notification]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_NOTIFICATION), {"p1": user_id, "p2": type, "p3": payload})).first() + if row is None: + return None + return models.Notification( + id=row[0], + user_id=row[1], + type=row[2], + payload=row[3], + read_at=row[4], + created_at=row[5], + ) + + async def list_notifications_by_user_id(self, *, user_id: uuid.UUID) -> AsyncIterator[models.Notification]: + result = await self._conn.stream(sqlalchemy.text(LIST_NOTIFICATIONS_BY_USER_ID), {"p1": user_id}) + async for row in result: + yield models.Notification( + id=row[0], + user_id=row[1], + type=row[2], + payload=row[3], + read_at=row[4], + created_at=row[5], + ) + + async def mark_notification_as_read(self, *, id: uuid.UUID, user_id: uuid.UUID) -> Optional[models.Notification]: + row = (await self._conn.execute(sqlalchemy.text(MARK_NOTIFICATION_AS_READ), {"p1": id, "p2": user_id})).first() + if row is None: + return None + return models.Notification( + id=row[0], + user_id=row[1], + type=row[2], + payload=row[3], + read_at=row[4], + created_at=row[5], + ) diff --git a/db/queries/audit.sql b/db/queries/audit.sql new file mode 100644 index 0000000..b244581 --- /dev/null +++ b/db/queries/audit.sql @@ -0,0 +1,20 @@ +-- name: CreateAuditEvent :one +INSERT INTO audit_events ( + event_type, + user_id, + metadata +) VALUES ( + $1, $2, $3 +) +RETURNING id, event_type, user_id, metadata, created_at; + +-- name: ListAuditEvents :many +SELECT id, event_type, user_id, metadata, created_at +FROM audit_events +WHERE event_type = COALESCE($1, event_type) + AND user_id = COALESCE($2, user_id) + AND created_at >= COALESCE($3, created_at) + AND created_at <= COALESCE($4, created_at) +ORDER BY created_at DESC +LIMIT $5 +OFFSET $6; diff --git a/db/queries/devices.sql b/db/queries/devices.sql index 91e9534..2b512d8 100644 --- a/db/queries/devices.sql +++ b/db/queries/devices.sql @@ -42,3 +42,32 @@ WHERE id =$1; SELECT COUNT(*) FROM user_devices WHERE user_id = $1; + +-- name: UpdateDevicePushToken :one +UPDATE user_devices +SET + push_token = $2, + is_active = TRUE, + is_invalid_token = FALSE +WHERE id = $1 +AND user_id = $3 +RETURNING *; + +-- name: ActivateDevice :exec +UPDATE user_devices +SET is_active = TRUE +WHERE id = $1 +AND user_id = $2; + +-- name: DeactivateDevice :exec +UPDATE user_devices +SET is_active = FALSE +WHERE id = $1 +AND user_id = $2; + +-- name: MarkDeviceTokenInvalid :exec +UPDATE user_devices +SET + is_invalid_token = TRUE, + is_active = FALSE +WHERE push_token = $1; diff --git a/db/queries/notifications.sql b/db/queries/notifications.sql new file mode 100644 index 0000000..b7dc9ec --- /dev/null +++ b/db/queries/notifications.sql @@ -0,0 +1,23 @@ +-- name: CreateNotification :one +INSERT INTO notifications ( + user_id, + type, + payload +) VALUES ( + $1, $2, $3 +) +RETURNING id, user_id, type, payload, read_at, created_at; + +-- name: ListNotificationsByUserID :many +SELECT id, user_id, type, payload, read_at, created_at +FROM notifications +WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: MarkNotificationAsRead :one +UPDATE notifications +SET read_at = NOW() +WHERE id = $1 + AND user_id = $2 + AND read_at IS NULL +RETURNING id, user_id, type, payload, read_at, created_at; diff --git a/makefile b/makefile index a5d58e5..3acf145 100644 --- a/makefile +++ b/makefile @@ -59,3 +59,6 @@ run-app: lint: uv run ruff check . + +check_type: + uv run mypy . diff --git a/migrations/sql/down/add-audit-table.sql b/migrations/sql/down/add-audit-table.sql new file mode 100644 index 0000000..0e7800a --- /dev/null +++ b/migrations/sql/down/add-audit-table.sql @@ -0,0 +1,5 @@ +ALTER TABLE public.audit_events DROP CONSTRAINT IF EXISTS audit_events_user_id_fkey; +DROP INDEX IF EXISTS idx_audit_events_event_type; +DROP INDEX IF EXISTS idx_audit_events_user_id; +DROP TABLE IF EXISTS public.audit_events; +DROP TYPE IF EXISTS public.audit_event_type; diff --git a/migrations/sql/down/add_device_push_token_fields.sql b/migrations/sql/down/add_device_push_token_fields.sql new file mode 100644 index 0000000..00d98b2 --- /dev/null +++ b/migrations/sql/down/add_device_push_token_fields.sql @@ -0,0 +1,6 @@ +DROP INDEX IF EXISTS idx_user_devices_push_token; + +ALTER TABLE user_devices + DROP COLUMN IF EXISTS is_invalid_token, + DROP COLUMN IF EXISTS is_active, + DROP COLUMN IF EXISTS push_token; diff --git a/migrations/sql/up/add-audit-table.sql b/migrations/sql/up/add-audit-table.sql new file mode 100644 index 0000000..430f043 --- /dev/null +++ b/migrations/sql/up/add-audit-table.sql @@ -0,0 +1,25 @@ +CREATE TYPE IF NOT EXISTS public.audit_event_type AS ENUM ( + 'user.signup', + 'user.login', + 'user.logout', + 'upload_request.created', + 'upload_request.approved', + 'upload_request.rejected' +); + +CREATE TABLE IF NOT EXISTS public.audit_events ( + id uuid DEFAULT public.uuid_generate_v4() NOT NULL, + event_type public.audit_event_type NOT NULL, + user_id uuid, + metadata jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_events_event_type ON public.audit_events USING btree (event_type); +CREATE INDEX IF NOT EXISTS idx_audit_events_user_id ON public.audit_events USING btree (user_id); + +ALTER TABLE ONLY public.audit_events + ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY public.audit_events + ADD CONSTRAINT audit_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE SET NULL; diff --git a/migrations/sql/up/add_device_push_token_fields.sql b/migrations/sql/up/add_device_push_token_fields.sql new file mode 100644 index 0000000..1cf361e --- /dev/null +++ b/migrations/sql/up/add_device_push_token_fields.sql @@ -0,0 +1,6 @@ +ALTER TABLE user_devices + ADD COLUMN push_token TEXT, + ADD COLUMN is_active BOOLEAN DEFAULT TRUE NOT NULL, + ADD COLUMN is_invalid_token BOOLEAN DEFAULT FALSE NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_devices_push_token ON user_devices (push_token) WHERE push_token IS NOT NULL; diff --git a/migrations/versions/a1f1d0b6e553_add_audit_table.py b/migrations/versions/a1f1d0b6e553_add_audit_table.py new file mode 100644 index 0000000..11ef5a9 --- /dev/null +++ b/migrations/versions/a1f1d0b6e553_add_audit_table.py @@ -0,0 +1,25 @@ +"""add-audit-table + +Revision ID: a1f1d0b6e553 +Revises: 5ead72a95638 +Create Date: 2026-03-20 00:00:00.000000 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +# revision identifiers, used by Alembic. +revision: str = 'a1f1d0b6e553' +down_revision: Union[str, Sequence[str], None] = '5ead72a95638' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add-audit-table") + + +def downgrade() -> None: + run_sql_down("add-audit-table") diff --git a/migrations/versions/d2c3e1f4a5b6_add_device_push_token_fields.py b/migrations/versions/d2c3e1f4a5b6_add_device_push_token_fields.py new file mode 100644 index 0000000..204b0b7 --- /dev/null +++ b/migrations/versions/d2c3e1f4a5b6_add_device_push_token_fields.py @@ -0,0 +1,25 @@ +"""add_device_push_token_fields + +Revision ID: d2c3e1f4a5b6 +Revises: 5ead72a95638 +Create Date: 2026-03-25 00:00:00.000000 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +# revision identifiers, used by Alembic. +revision: str = 'd2c3e1f4a5b6' +down_revision: Union[str, Sequence[str], None] = '5ead72a95638' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_device_push_token_fields") + + +def downgrade() -> None: + run_sql_down("add_device_push_token_fields") diff --git a/pyproject.toml b/pyproject.toml index c9d7300..ca0bd4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,9 @@ dependencies = [ "insightface>=0.7.3", "onnxruntime>=1.24.4", "python-multipart>=0.0.22", + "firebase-admin>=6.8.0", + "apns2>=0.7.1", + "pywebpush>=2.3.0", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index c6884ac..f15e868 100644 --- a/uv.lock +++ b/uv.lock @@ -203,6 +203,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "apns2" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "hyper" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/a1/0c66ac293963b132e7eeb8edf5fa035e96eae3262623305704c88df876e6/apns2-0.7.1.tar.gz", hash = "sha256:8c24207aa96dff4687f8d7c9149fc42086f3506b0a76da1f5bf48d74e5569567", size = 11052, upload-time = "2019-10-08T19:52:56.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/91/f27cd1299e00eb6955199856f19327de73c5eb803503356fd238b81d3430/apns2-0.7.1-py2.py3-none-any.whl", hash = "sha256:360bcd1f1d6308348adcb317c0192d1631fe01a2b9b73ce95f57c708de2bb88a", size = 9945, upload-time = "2019-10-08T19:52:54.313Z" }, +] + [[package]] name = "argon2-cffi" version = "23.1.0" @@ -301,10 +315,12 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, + { name = "apns2" }, { name = "asyncpg" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, + { name = "firebase-admin" }, { name = "greenlet" }, { name = "insightface" }, { name = "miniopy-async" }, @@ -319,6 +335,7 @@ dependencies = [ { name = "pyjwt" }, { name = "pyotp" }, { name = "python-multipart" }, + { name = "pywebpush" }, { name = "redis" }, { name = "setuptools" }, ] @@ -333,10 +350,12 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, + { name = "apns2", specifier = ">=0.7.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, { name = "insightface", specifier = ">=0.7.3" }, { name = "miniopy-async", specifier = ">=1.23.4" }, @@ -351,6 +370,7 @@ requires-dist = [ { name = "pyjwt", specifier = ">=2.11.0" }, { name = "pyotp", specifier = ">=2.9.0" }, { name = "python-multipart", specifier = ">=0.0.22" }, + { name = "pywebpush", specifier = ">=2.3.0" }, { name = "redis", specifier = ">=7.2.1" }, { name = "setuptools", specifier = ">=82.0.0" }, ] @@ -412,6 +432,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, ] +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -896,6 +929,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "firebase-admin" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol" }, + { name = "google-api-core", extra = ["grpc"], marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-api-python-client" }, + { name = "google-cloud-firestore", marker = "platform_python_implementation != 'PyPy'" }, + { name = "google-cloud-storage" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/d8/4230b0770a2cd9d4de53bb1f0a17fa204716a9b271bc3be1fb109dfb8b9d/firebase_admin-6.8.0.tar.gz", hash = "sha256:24a9870219cfd6578586357858e00758aea26a39df74c53be5d803f5654d883e", size = 112211, upload-time = "2025-04-24T18:53:24.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/e4/a4fea0c28787e6fadfdc6bf76f497c8136fdbb915f2942de1070918c1202/firebase_admin-6.8.0-py3-none-any.whl", hash = "sha256:84d5fd82859c4d27b63338c3fe9724667dfe400aa2fd9fef0efffbf6e23bca82", size = 134188, upload-time = "2025-04-24T18:53:23.182Z" }, +] + [[package]] name = "flatbuffers" version = "25.12.19" @@ -1034,6 +1084,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "google-api-core" +version = "2.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/98/586ec94553b569080caef635f98a3723db36a38eac0e3d7eb3ea9d2e4b9a/google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b", size = 176959, upload-time = "2026-02-18T20:28:11.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/27/09c33d67f7e0dcf06d7ac17d196594e66989299374bfb0d4331d1038e76b/google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5", size = 173288, upload-time = "2026-02-18T20:28:10.367Z" }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.193.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/f4/e14b6815d3b1885328dd209676a3a4c704882743ac94e18ef0093894f5c8/google_api_python_client-2.193.0.tar.gz", hash = "sha256:8f88d16e89d11341e0a8b199cafde0fb7e6b44260dffb88d451577cbd1bb5d33", size = 14281006, upload-time = "2026-03-17T18:25:29.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/6d/fe75167797790a56d17799b75e1129bb93f7ff061efc7b36e9731bd4be2b/google_api_python_client-2.193.0-py3-none-any.whl", hash = "sha256:c42aa324b822109901cfecab5dc4fc3915d35a7b376835233c916c70610322db", size = 14856490, upload-time = "2026-03-17T18:25:26.608Z" }, +] + +[[package]] +name = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134, upload-time = "2025-12-15T22:13:51.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529, upload-time = "2025-12-15T22:13:51.048Z" }, +] + +[[package]] +name = "google-cloud-core" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, +] + +[[package]] +name = "google-cloud-firestore" +version = "2.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "grpcio" }, + { name = "proto-plus" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/84/4acfdc4d29de4eae4dd6ac1267611421c2a36975c473b2a86fd2e9752e75/google_cloud_firestore-2.25.0.tar.gz", hash = "sha256:9bca3b504f5473048eeab603b9bec69bbeffcdddc4e5fc65cdcc01b449628fc0", size = 621860, upload-time = "2026-03-12T19:31:06.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/f2/29abde0fcd98f32ce61fc2064a1a3e39b4e64746537409f8ea5521f79afb/google_cloud_firestore-2.25.0-py3-none-any.whl", hash = "sha256:c933a7696b7dd160953d60413ab9481387f6dd8367e77dd750d841689773104a", size = 416714, upload-time = "2026-03-12T19:30:36.674Z" }, +] + +[[package]] +name = "google-cloud-storage" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-cloud-core" }, + { name = "google-crc32c" }, + { name = "google-resumable-media" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/e3/747759eebc72e420c25903d6bc231d0ceb110b66ac7e6ee3f350417152cd/google_cloud_storage-3.10.0.tar.gz", hash = "sha256:1aeebf097c27d718d84077059a28d7e87f136f3700212215f1ceeae1d1c5d504", size = 17309829, upload-time = "2026-03-18T15:54:11.875Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/e2/d58442f4daee5babd9255cf492a1f3d114357164072f8339a22a3ad460a2/google_cloud_storage-3.10.0-py3-none-any.whl", hash = "sha256:0072e7783b201e45af78fd9779894cdb6bec2bf922ee932f3fcc16f8bce9b9a3", size = 324382, upload-time = "2026-03-18T15:54:10.091Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "google-resumable-media" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-crc32c" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.73.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/96/a0205167fa0154f4a542fd6925bdc63d039d88dab3588b875078107e6f06/googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a", size = 147323, upload-time = "2026-03-06T21:53:09.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8", size = 297578, upload-time = "2026-03-06T21:52:33.933Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -1077,6 +1285,61 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, ] +[[package]] +name = "grpcio" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/8a/3d098f35c143a89520e568e6539cc098fcd294495910e359889ce8741c84/grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5", size = 12852416, upload-time = "2026-02-06T09:57:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/f4/7384ed0178203d6074446b3c4f46c90a22ddf7ae0b3aee521627f54cfc2a/grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97", size = 5913985, upload-time = "2026-02-06T09:55:26.832Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/be1caa25f06594463f685b3790b320f18aea49b33166f4141bfdc2bfb236/grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e", size = 11811853, upload-time = "2026-02-06T09:55:29.224Z" }, + { url = "https://files.pythonhosted.org/packages/24/a7/f06d151afc4e64b7e3cc3e872d331d011c279aaab02831e40a81c691fb65/grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996", size = 6475766, upload-time = "2026-02-06T09:55:31.825Z" }, + { url = "https://files.pythonhosted.org/packages/8a/a8/4482922da832ec0082d0f2cc3a10976d84a7424707f25780b82814aafc0a/grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7", size = 7170027, upload-time = "2026-02-06T09:55:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/54/bf/f4a3b9693e35d25b24b0b39fa46d7d8a3c439e0a3036c3451764678fec20/grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9", size = 6690766, upload-time = "2026-02-06T09:55:36.902Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b9/521875265cc99fe5ad4c5a17010018085cae2810a928bf15ebe7d8bcd9cc/grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383", size = 7266161, upload-time = "2026-02-06T09:55:39.824Z" }, + { url = "https://files.pythonhosted.org/packages/05/86/296a82844fd40a4ad4a95f100b55044b4f817dece732bf686aea1a284147/grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6", size = 8253303, upload-time = "2026-02-06T09:55:42.353Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ea3c0caf5468537f27ad5aab92b681ed7cc0ef5f8c9196d3fd42c8c2286b/grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce", size = 7698222, upload-time = "2026-02-06T09:55:44.629Z" }, + { url = "https://files.pythonhosted.org/packages/d7/47/7f05f81e4bb6b831e93271fb12fd52ba7b319b5402cbc101d588f435df00/grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68", size = 4066123, upload-time = "2026-02-06T09:55:47.644Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e7/d6914822c88aa2974dbbd10903d801a28a19ce9cd8bad7e694cbbcf61528/grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e", size = 4797657, upload-time = "2026-02-06T09:55:49.86Z" }, + { url = "https://files.pythonhosted.org/packages/05/a9/8f75894993895f361ed8636cd9237f4ab39ef87fd30db17467235ed1c045/grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b", size = 5920143, upload-time = "2026-02-06T09:55:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/55/06/0b78408e938ac424100100fd081189451b472236e8a3a1f6500390dc4954/grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a", size = 11803926, upload-time = "2026-02-06T09:55:55.494Z" }, + { url = "https://files.pythonhosted.org/packages/88/93/b59fe7832ff6ae3c78b813ea43dac60e295fa03606d14d89d2e0ec29f4f3/grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84", size = 6478628, upload-time = "2026-02-06T09:55:58.533Z" }, + { url = "https://files.pythonhosted.org/packages/ed/df/e67e3734527f9926b7d9c0dde6cd998d1d26850c3ed8eeec81297967ac67/grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb", size = 7173574, upload-time = "2026-02-06T09:56:01.786Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/cc03fffb07bfba982a9ec097b164e8835546980aec25ecfa5f9c1a47e022/grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5", size = 6692639, upload-time = "2026-02-06T09:56:04.529Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9a/289c32e301b85bdb67d7ec68b752155e674ee3ba2173a1858f118e399ef3/grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9", size = 7268838, upload-time = "2026-02-06T09:56:08.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/79/1be93f32add280461fa4773880196572563e9c8510861ac2da0ea0f892b6/grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702", size = 8251878, upload-time = "2026-02-06T09:56:10.914Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/793f8e95296ab92e4164593674ae6291b204bb5f67f9d4a711489cd30ffa/grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20", size = 7695412, upload-time = "2026-02-06T09:56:13.593Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9f/1e233fe697ecc82845942c2822ed06bb522e70d6771c28d5528e4c50f6a4/grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670", size = 4064899, upload-time = "2026-02-06T09:56:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/4d/27/d86b89e36de8a951501fb06a0f38df19853210f341d0b28f83f4aa0ffa08/grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4", size = 4797393, upload-time = "2026-02-06T09:56:17.882Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/b56e43e3c968bfe822fa6ce5bca10d5c723aa40875b48791ce1029bb78c7/grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e", size = 5920591, upload-time = "2026-02-06T09:56:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/5d/81/1f3b65bd30c334167bfa8b0d23300a44e2725ce39bba5b76a2460d85f745/grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f", size = 11813685, upload-time = "2026-02-06T09:56:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/0e/1c/bbe2f8216a5bd3036119c544d63c2e592bdf4a8ec6e4a1867592f4586b26/grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724", size = 6487803, upload-time = "2026-02-06T09:56:27.367Z" }, + { url = "https://files.pythonhosted.org/packages/16/5c/a6b2419723ea7ddce6308259a55e8e7593d88464ce8db9f4aa857aba96fa/grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b", size = 7173206, upload-time = "2026-02-06T09:56:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/df/1e/b8801345629a415ea7e26c83d75eb5dbe91b07ffe5210cc517348a8d4218/grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7", size = 6693826, upload-time = "2026-02-06T09:56:32.305Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/0de28eac0377742679a510784f049738a80424b17287739fc47d63c2439e/grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452", size = 7277897, upload-time = "2026-02-06T09:56:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9c/ad8685cfe20559a9edb66f735afdcb2b7d3de69b13666fdfc542e1916ebd/grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127", size = 8252404, upload-time = "2026-02-06T09:56:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/3c/05/33a7a4985586f27e1de4803887c417ec7ced145ebd069bc38a9607059e2b/grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65", size = 7696837, upload-time = "2026-02-06T09:56:40.173Z" }, + { url = "https://files.pythonhosted.org/packages/73/77/7382241caf88729b106e49e7d18e3116216c778e6a7e833826eb96de22f7/grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c", size = 4142439, upload-time = "2026-02-06T09:56:43.258Z" }, + { url = "https://files.pythonhosted.org/packages/48/b2/b096ccce418882fbfda4f7496f9357aaa9a5af1896a9a7f60d9f2b275a06/grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb", size = 4929852, upload-time = "2026-02-06T09:56:45.885Z" }, +] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "grpcio" }, + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/cd/89ce482a931b543b92cdd9b2888805518c4620e0094409acb8c81dd4610a/grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189", size = 13808, upload-time = "2026-02-06T10:01:48.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/8a/1241ec22c41028bddd4a052ae9369267b4475265ad0ce7140974548dc3fa/grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34", size = 14523, upload-time = "2026-02-06T10:01:32.584Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1086,6 +1349,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/ad/73a6c1a40eadbf9eef93fe16285a366c834cbd61783c30e6c23ef4b11e53/h2-2.6.2.tar.gz", hash = "sha256:af35878673c83a44afbc12b13ac91a489da2819b5dc1e11768f3c2406f740fe9", size = 169942, upload-time = "2017-04-03T07:56:34.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8b/8d5610e8ddbcde6d014907526b4c6c294520a7233fc456d7be1fcade3bbc/h2-2.6.2-py2.py3-none-any.whl", hash = "sha256:93cbd1013a2218539af05cdf9fc37b786655b93bbc94f5296b7dabd1c5cadf41", size = 71894, upload-time = "2017-04-03T07:56:30.674Z" }, +] + +[[package]] +name = "hpack" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/f1/b4440e46e265a29c0cb7b09b6daec6edf93c79eae713cfed93fbbf8716c5/hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2", size = 43321, upload-time = "2017-03-29T13:00:11.691Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/cc/e53517f4a1e13f74776ca93271caef378dadec14d71c61c949d759d3db69/hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", size = 38552, upload-time = "2017-03-29T13:00:09.659Z" }, +] + +[[package]] +name = "http-ece" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" } + [[package]] name = "httpcore" version = "1.0.9" @@ -1099,6 +1393,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, +] + [[package]] name = "httptools" version = "0.7.1" @@ -1143,6 +1449,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hyper" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h2" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f7/f60d8032f331994f29ce2d79fb5d7fe1e3c1355cac0078c070cf4feb3b52/hyper-0.7.0.tar.gz", hash = "sha256:12c82eacd122a659673484c1ea0d34576430afbe5aa6b8f63fe37fcb06a2458c", size = 631878, upload-time = "2016-09-27T12:58:46.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/c3/e77072050a8d3a22255695d0cd7fde19bfe962364a6f6870ef47a9f9f66b/hyper-0.7.0-py2.py3-none-any.whl", hash = "sha256:069514f54231fb7b5df2fb910a114663a83306d5296f588fffcb0a9be19407fc", size = 269790, upload-time = "2016-09-27T12:58:42.841Z" }, +] + +[[package]] +name = "hyperframe" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/96/7080c938d2b06105365bae946c77c78a32d9e763eaa05d0e431b02d7bc12/hyperframe-3.2.0.tar.gz", hash = "sha256:05f0e063e117c16fcdd13c12c93a4424a2c40668abfac3bb419a10f57698204e", size = 16177, upload-time = "2016-02-02T14:45:41.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/89/44ff46f15dba53a8c16cb8cab89ecb1e44f8aa211628b43d341004cfcf7a/hyperframe-3.2.0-py2.py3-none-any.whl", hash = "sha256:4dcab11967482d400853b396d042038e4c492a15a5d2f57259e2b5f89a32f755", size = 13636, upload-time = "2016-02-02T14:45:48.989Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1576,6 +1904,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, ] +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/bd/8b0d01c756203fbab65d265859749860682ccd2a59594609aeec3a144efa/msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa", size = 81939, upload-time = "2025-10-08T09:15:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/ba4f155f793a74c1483d4bdef136e1023f7bcba557f0db4ef3db3c665cf1/msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb", size = 85064, upload-time = "2025-10-08T09:15:03.764Z" }, + { url = "https://files.pythonhosted.org/packages/f2/60/a064b0345fc36c4c3d2c743c82d9100c40388d77f0b48b2f04d6041dbec1/msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f", size = 417131, upload-time = "2025-10-08T09:15:05.136Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/a5100f7185a800a5d29f8d14041f61475b9de465ffcc0f3b9fba606e4505/msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42", size = 427556, upload-time = "2025-10-08T09:15:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/f5/87/ffe21d1bf7d9991354ad93949286f643b2bb6ddbeab66373922b44c3b8cc/msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9", size = 404920, upload-time = "2025-10-08T09:15:08.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/8543ed2b8604f7c0d89ce066f42007faac1eaa7d79a81555f206a5cdb889/msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620", size = 415013, upload-time = "2025-10-08T09:15:09.83Z" }, + { url = "https://files.pythonhosted.org/packages/41/0d/2ddfaa8b7e1cee6c490d46cb0a39742b19e2481600a7a0e96537e9c22f43/msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029", size = 65096, upload-time = "2025-10-08T09:15:11.11Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ec/d431eb7941fb55a31dd6ca3404d41fbb52d99172df2e7707754488390910/msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b", size = 72708, upload-time = "2025-10-08T09:15:12.554Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/5b1a1f70eb0e87d1678e9624908f86317787b536060641d6798e3cf70ace/msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69", size = 64119, upload-time = "2025-10-08T09:15:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -2069,19 +2441,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929, upload-time = "2026-02-02T17:34:49.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480, upload-time = "2026-02-02T17:34:47.339Z" }, +] + [[package]] name = "protobuf" -version = "7.34.0" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/00/04a2ab36b70a52d0356852979e08b44edde0435f2115dc66e25f2100f3ab/protobuf-7.34.0.tar.gz", hash = "sha256:3871a3df67c710aaf7bb8d214cc997342e63ceebd940c8c7fc65c9b3d697591a", size = 454726, upload-time = "2026-02-27T00:30:25.421Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/c4/6322ab5c8f279c4c358bc14eb8aefc0550b97222a39f04eb3c1af7a830fa/protobuf-7.34.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e329966799f2c271d5e05e236459fe1cbfdb8755aaa3b0914fa60947ddea408", size = 429248, upload-time = "2026-02-27T00:30:14.924Z" }, - { url = "https://files.pythonhosted.org/packages/45/99/b029bbbc61e8937545da5b79aa405ab2d9cf307a728f8c9459ad60d7a481/protobuf-7.34.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:9d7a5005fb96f3c1e64f397f91500b0eb371b28da81296ae73a6b08a5b76cdd6", size = 325753, upload-time = "2026-02-27T00:30:17.247Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/09f02671eb75b251c5550a1c48e7b3d4b0623efd7c95a15a50f6f9fc1e2e/protobuf-7.34.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4a72a8ec94e7a9f7ef7fe818ed26d073305f347f8b3b5ba31e22f81fd85fca02", size = 340200, upload-time = "2026-02-27T00:30:18.672Z" }, - { url = "https://files.pythonhosted.org/packages/b5/57/89727baef7578897af5ed166735ceb315819f1c184da8c3441271dbcfde7/protobuf-7.34.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:964cf977e07f479c0697964e83deda72bcbc75c3badab506fb061b352d991b01", size = 324268, upload-time = "2026-02-27T00:30:20.088Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3e/38ff2ddee5cc946f575c9d8cc822e34bde205cf61acf8099ad88ef19d7d2/protobuf-7.34.0-cp310-abi3-win32.whl", hash = "sha256:f791ec509707a1d91bd02e07df157e75e4fb9fbdad12a81b7396201ec244e2e3", size = 426628, upload-time = "2026-02-27T00:30:21.555Z" }, - { url = "https://files.pythonhosted.org/packages/cb/71/7c32eaf34a61a1bae1b62a2ac4ffe09b8d1bb0cf93ad505f42040023db89/protobuf-7.34.0-cp310-abi3-win_amd64.whl", hash = "sha256:9f9079f1dde4e32342ecbd1c118d76367090d4aaa19da78230c38101c5b3dd40", size = 437901, upload-time = "2026-02-27T00:30:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/a4/e7/14dc9366696dcb53a413449881743426ed289d687bcf3d5aee4726c32ebb/protobuf-7.34.0-py3-none-any.whl", hash = "sha256:e3b914dd77fa33fa06ab2baa97937746ab25695f389869afdf03e81f34e45dc7", size = 170716, upload-time = "2026-02-27T00:30:23.994Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -2097,6 +2481,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] +[[package]] +name = "py-vapid" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ed/c648c8018fab319951764f4babe68ddcbbff7f2bbcd7ff7e531eac1788c8/py_vapid-1.9.4.tar.gz", hash = "sha256:a004023560cbc54e34fc06380a0580f04ffcc788e84fb6d19e9339eeb6551a28", size = 74750, upload-time = "2026-01-05T22:13:25.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/15/f9d0171e1ad863ca49e826d5afb6b50566f20dc9b4f76965096d3555ce9e/py_vapid-1.9.4-py2.py3-none-any.whl", hash = "sha256:f165a5bf90dcf966b226114f01f178f137579a09784c7f0628fa2f0a299741b6", size = 23912, upload-time = "2026-01-05T20:42:05.455Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -2272,6 +2689,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, ] +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyotp" version = "2.9.0" @@ -2320,6 +2742,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pywebpush" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "cryptography" }, + { name = "http-ece" }, + { name = "py-vapid" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d9/e497a24bc9f659bfc0e570382a41e6b2d6726fbcfa4d85aaa23fe9c81ba2/pywebpush-2.3.0.tar.gz", hash = "sha256:d1e27db8de9e6757c1875f67292554bd54c41874c36f4b5c4ebb5442dce204f2", size = 28489, upload-time = "2026-02-09T23:30:18.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/ac21241cf8007cb93255eabf318da4f425ec0f75d28c366992253aa8c1b2/pywebpush-2.3.0-py3-none-any.whl", hash = "sha256:3d97469fb14d4323c362319d438183737249a4115b50e146ce233e7f01e3cf98", size = 22851, upload-time = "2026-02-09T23:30:16.093Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2965,6 +3403,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, ] +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + [[package]] name = "urllib3" version = "2.6.3"