diff --git a/app/container.py b/app/container.py index 2c921b7e..d1f02a7c 100644 --- a/app/container.py +++ b/app/container.py @@ -7,6 +7,7 @@ from app.service.device import DeviceService from app.service.face_embedding import FaceEmbeddingService from app.service.session import SessionService +from app.service.staged_upload_storage import StagedUploadStorageService from app.service.staff_drive import StaffDriveService from app.service.staff_notifications import StaffNotificationsService from app.service.staff_user import StaffUserService @@ -71,11 +72,14 @@ def __init__( self.staff_notifications_service = StaffNotificationsService( notification_querier=self.staff_notification_querier, ) + self.staged_upload_storage_service = StagedUploadStorageService() self.upload_requests_service = UploadRequestsService( upload_request_querier=self.upload_request_querier, upload_request_photo_querier=self.upload_request_photo_querier, photo_querier=self.photo_querier, + staged_upload_storage=self.staged_upload_storage_service, + staff_drive_service=self.staff_drive_service, staff_notifications_service=self.staff_notifications_service, ) diff --git a/app/infra/google_drive.py b/app/infra/google_drive.py index a51413fe..0b32ad66 100644 --- a/app/infra/google_drive.py +++ b/app/infra/google_drive.py @@ -5,6 +5,7 @@ import urllib.request from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from email.message import Message from app.core.exceptions import AppException from app.core.config import settings @@ -13,6 +14,7 @@ GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" +GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" @dataclass @@ -31,6 +33,20 @@ class GoogleUserInfo: verified_email: bool +@dataclass +class GoogleDriveFileMetadata: + id: str + name: str + mime_type: str + size_bytes: int + + +@dataclass +class GoogleDriveFileDownload: + metadata: GoogleDriveFileMetadata + content: bytes + + class GoogleDriveClient: @staticmethod def _require_str(data: dict[str, object], key: str) -> str: @@ -107,6 +123,7 @@ async def get_user_info(access_token: str) -> GoogleUserInfo: data = await GoogleDriveClient._get_json( GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"}, + error_context="Google user info request", ) return GoogleUserInfo( id=GoogleDriveClient._require_str(data, "id"), @@ -114,6 +131,56 @@ async def get_user_info(access_token: str) -> GoogleUserInfo: verified_email=bool(data.get("verified_email", False)), ) + @staticmethod + async def get_file_metadata( + *, + access_token: str, + file_id: str, + ) -> GoogleDriveFileMetadata: + data = await GoogleDriveClient._get_json( + GOOGLE_DRIVE_FILES_URL.format(file_id=urllib.parse.quote(file_id, safe="")), + headers={"Authorization": f"Bearer {access_token}"}, + query_params={ + "fields": "id,name,mimeType,size", + "supportsAllDrives": "true", + }, + error_context="Google Drive file metadata request", + ) + size_raw = data.get("size", "0") + if not isinstance(size_raw, (str, int)): + raise AppException.bad_request("Google Drive file size is invalid") + try: + size_bytes = int(size_raw) + except (TypeError, ValueError) as exc: + raise AppException.bad_request("Google Drive file size is invalid") from exc + + return GoogleDriveFileMetadata( + id=GoogleDriveClient._require_str(data, "id"), + name=GoogleDriveClient._require_str(data, "name"), + mime_type=GoogleDriveClient._require_str(data, "mimeType"), + size_bytes=size_bytes, + ) + + @staticmethod + async def download_file( + *, + access_token: str, + file_id: str, + ) -> GoogleDriveFileDownload: + metadata = await GoogleDriveClient.get_file_metadata( + access_token=access_token, + file_id=file_id, + ) + content, _, _ = await GoogleDriveClient._get_bytes( + GOOGLE_DRIVE_FILES_URL.format(file_id=urllib.parse.quote(file_id, safe="")), + headers={"Authorization": f"Bearer {access_token}"}, + query_params={ + "alt": "media", + "supportsAllDrives": "true", + }, + ) + return GoogleDriveFileDownload(metadata=metadata, content=content) + @staticmethod async def _post_form(url: str, payload: dict[str, str]) -> dict[str, object]: encoded = urllib.parse.urlencode(payload).encode("utf-8") @@ -144,16 +211,21 @@ def _request() -> dict[str, object]: async def _get_json( url: str, headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + error_context: str = "Google API request", ) -> dict[str, object]: def _request() -> dict[str, object]: - request = urllib.request.Request(url, headers=headers or {}, method="GET") + final_url = url + if query_params: + final_url = f"{url}?{urllib.parse.urlencode(query_params)}" + request = urllib.request.Request(final_url, headers=headers or {}, method="GET") try: with urllib.request.urlopen(request, timeout=15) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: details = exc.read().decode("utf-8", errors="ignore") raise AppException.bad_request( - f"Google user info request failed: {details or exc.reason}" + f"{error_context} failed: {details or exc.reason}" ) from exc except urllib.error.URLError as exc: raise AppException.internal_error( @@ -161,3 +233,31 @@ def _request() -> dict[str, object]: ) from exc return await asyncio.to_thread(_request) + + @staticmethod + async def _get_bytes( + url: str, + headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + ) -> tuple[bytes, str, str]: + def _request() -> tuple[bytes, str, str]: + final_url = url + if query_params: + final_url = f"{url}?{urllib.parse.urlencode(query_params)}" + request = urllib.request.Request(final_url, headers=headers or {}, method="GET") + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read() + response_headers: Message = response.headers + content_type = response_headers.get_content_type() + file_name = response_headers.get_filename() or "" + return body, content_type, file_name + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise AppException.bad_request( + f"Google file download failed: {details or exc.reason}" + ) from exc + except urllib.error.URLError as exc: + raise AppException.internal_error("Unable to download file from Google Drive") from exc + + return await asyncio.to_thread(_request) diff --git a/app/infra/minio.py b/app/infra/minio.py index 162e0f0c..09104eae 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -1,7 +1,9 @@ +import io import random import string import uuid from fastapi import UploadFile +from miniopy_async.commonconfig import CopySource from miniopy_async.error import S3Error from miniopy_async.api import Minio @@ -36,6 +38,11 @@ def __init__(self, bucket_name: str, file_prefix: str): self.bucket_name = bucket_name self.file_prefix = file_prefix + def _object_path(self, object_name: str) -> str: + if self.file_prefix: + return f"{self.file_prefix}/{object_name}" + return object_name + async def put(self, file: UploadFile, object_name: str | None = None) -> str: if object_name is None: object_name = str(uuid.uuid4()) @@ -48,7 +55,7 @@ async def put(self, file: UploadFile, object_name: str | None = None) -> str: await self.client.put_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), data=file.file, length=-1, part_size=10 * 1024 * 1024, @@ -63,7 +70,7 @@ async def get(self, object_name: str) -> tuple[bytes, str, str]: try: res = await self.client.get_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), ) except S3Error as e: if e.code == "NoSuchKey": @@ -83,8 +90,38 @@ async def get(self, object_name: str) -> tuple[bytes, str, str]: async def delete(self, object_name: str) -> None: await self.client.remove_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), + ) + + async def put_bytes( + self, + *, + data: bytes, + object_name: str, + content_type: str, + filename: str | None = None, + ) -> str: + await self.client.put_object( + bucket_name=self.bucket_name, + object_name=self._object_path(object_name), + data=io.BytesIO(data), + length=len(data), + part_size=10 * 1024 * 1024, + content_type=content_type, + metadata={"filename": filename or object_name}, + ) + return object_name + + async def copy(self, *, source_object_name: str, target_object_name: str) -> str: + await self.client.copy_object( + bucket_name=self.bucket_name, + object_name=self._object_path(target_object_name), + source=CopySource( + self.bucket_name, + self._object_path(source_object_name), + ), ) + return target_object_name image_ext_content_type_map = { "apng": ["image/apng"], diff --git a/app/infra/nats.py b/app/infra/nats.py index 558aa319..d2d24549 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -15,6 +15,9 @@ class NatsSubjects(Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" + STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" + STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" class NatsClient: _nc: Optional[NATS] = None diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py index eff98ab5..e17b02c2 100644 --- a/app/router/staff/__init__.py +++ b/app/router/staff/__init__.py @@ -1,6 +1,10 @@ -from app.router.staff.drive import router as staff_drive_router from fastapi import APIRouter +from app.router.staff.drive import router as staff_drive_router +from app.router.staff.notifications import router as staff_notifications_router +from app.router.staff.uploads import router as staff_uploads_router -router = APIRouter(prefix="/stuff",tags=["stuff"]) +router = APIRouter(prefix="/stuff", tags=["stuff"]) router.include_router(staff_drive_router) +router.include_router(staff_notifications_router) +router.include_router(staff_uploads_router) diff --git a/app/router/staff/uploads.py b/app/router/staff/uploads.py index 64440605..cac4277d 100644 --- a/app/router/staff/uploads.py +++ b/app/router/staff/uploads.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response from app.container import Container, get_container from app.deps.staff_auth import ( @@ -14,6 +15,7 @@ ) from app.schema.response.staff.uploads import ( UploadRequestListResponse, + UploadRequestPhotoListResponse, UploadRequestSchema, ) from db.generated.models import StaffUser, UploadRequestStatus @@ -53,6 +55,48 @@ async def list_upload_requests( ) +@router.get("/{request_id}", response_model=UploadRequestSchema) +async def get_upload_request( + request_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestSchema: + upload_request = await container.upload_requests_service.get_request_details( + request_id=request_id, + current_staff_user=current_staff_user, + ) + return UploadRequestSchema.from_models(upload_request.request, upload_request.photos) + + +@router.get("/{request_id}/photos", response_model=UploadRequestPhotoListResponse) +async def list_upload_request_photos( + request_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestPhotoListResponse: + upload_request = await container.upload_requests_service.get_request_details( + request_id=request_id, + current_staff_user=current_staff_user, + ) + return UploadRequestPhotoListResponse.from_models(upload_request.photos) + + +@router.get("/{request_id}/photos/{photo_id}/preview") +async def preview_upload_request_photo( + request_id: UUID, + photo_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> Response: + preview = await container.upload_requests_service.get_request_photo_preview( + request_id=request_id, + photo_id=photo_id, + current_staff_user=current_staff_user, + ) + headers = {"Content-Disposition": f'inline; filename="{preview.file_name}"'} + return Response(content=preview.data, media_type=preview.content_type, headers=headers) + + @router.post("/{request_id}/approve", response_model=UploadRequestSchema) async def approve_upload_request( request_id: UUID, diff --git a/app/schema/dto/staff/uploads.py b/app/schema/dto/staff/uploads.py index d64670d4..c8b91da5 100644 --- a/app/schema/dto/staff/uploads.py +++ b/app/schema/dto/staff/uploads.py @@ -5,9 +5,6 @@ @dataclass(frozen=True) class UploadPhotoInput: drive_file_id: str - file_name: str - mime_type: str - size_bytes: int taken_at: datetime | None day_number: int | None visibility: str diff --git a/app/schema/request/staff/uploads.py b/app/schema/request/staff/uploads.py index d83f7cd0..191796d1 100644 --- a/app/schema/request/staff/uploads.py +++ b/app/schema/request/staff/uploads.py @@ -1,48 +1,27 @@ from datetime import datetime -from pathlib import PurePath -from typing import ClassVar -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator from uuid import UUID from app.schema.dto.staff.uploads import UploadPhotoInput MAX_UPLOAD_BATCH_SIZE = 20 -MAX_UPLOAD_PHOTO_SIZE_BYTES = 20 * 1024 * 1024 -ALLOWED_IMAGE_MIME_TYPES: dict[str, tuple[str, ...]] = { - "image/jpeg": (".jpg", ".jpeg"), - "image/png": (".png",), - "image/webp": (".webp",), -} class CreateUploadRequestPhotoRequest(BaseModel): drive_file_id: str = Field(min_length=1, max_length=255) - file_name: str = Field(min_length=1, max_length=255) - mime_type: str - size_bytes: int = Field(gt=0, le=MAX_UPLOAD_PHOTO_SIZE_BYTES) taken_at: datetime | None = None day_number: int | None = None visibility: str = "private" - _allowed_mime_types: ClassVar[dict[str, tuple[str, ...]]] = ALLOWED_IMAGE_MIME_TYPES - - @field_validator("drive_file_id", "file_name", mode="before") + @field_validator("drive_file_id", mode="before") @classmethod def _strip_required_text(cls, value: object) -> object: if isinstance(value, str): return value.strip() return value - @field_validator("mime_type") - @classmethod - def _validate_mime_type(cls, value: str) -> str: - normalized_value = value.strip().lower() - if normalized_value not in cls._allowed_mime_types: - raise ValueError("Unsupported image format") - return normalized_value - @field_validator("visibility") @classmethod def _validate_visibility(cls, value: str) -> str: @@ -51,20 +30,9 @@ def _validate_visibility(cls, value: str) -> str: raise ValueError("visibility must be either 'private' or 'public'") return normalized_value - @model_validator(mode="after") - def _validate_file_extension(self) -> "CreateUploadRequestPhotoRequest": - extension = PurePath(self.file_name).suffix.lower() - allowed_extensions = self._allowed_mime_types[self.mime_type] - if extension not in allowed_extensions: - raise ValueError("file_name extension does not match mime_type") - return self - def to_input(self) -> UploadPhotoInput: return UploadPhotoInput( drive_file_id=self.drive_file_id, - file_name=self.file_name, - mime_type=self.mime_type, - size_bytes=self.size_bytes, taken_at=self.taken_at, day_number=self.day_number, visibility=self.visibility, diff --git a/app/schema/response/staff/uploads.py b/app/schema/response/staff/uploads.py index 8e2d01ee..6b296cfa 100644 --- a/app/schema/response/staff/uploads.py +++ b/app/schema/response/staff/uploads.py @@ -9,9 +9,13 @@ class UploadRequestSchema(BaseModel): class UploadRequestPhotoSchema(BaseModel): id: UUID drive_file_id: str + file_name: str + mime_type: str + size_bytes: int taken_at: datetime | None day_number: int | None visibility: str + status: str created_at: datetime @classmethod @@ -22,9 +26,13 @@ def from_model( return cls( id=photo.id, drive_file_id=photo.drive_file_id, + file_name=photo.file_name, + mime_type=photo.mime_type, + size_bytes=photo.size_bytes, taken_at=photo.taken_at, day_number=photo.day_number, visibility=photo.visibility, + status=photo.status, created_at=photo.created_at, ) @@ -75,3 +83,16 @@ def from_models( for upload_request, photos in items ] ) + + +class UploadRequestPhotoListResponse(BaseModel): + items: list[UploadRequestSchema.UploadRequestPhotoSchema] + + @classmethod + def from_models( + cls, + photos: list[UploadRequestPhoto], + ) -> "UploadRequestPhotoListResponse": + return cls( + items=[UploadRequestSchema.UploadRequestPhotoSchema.from_model(photo) for photo in photos] + ) diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index a82fc312..4e4019f1 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -90,6 +90,19 @@ async def get_status(self, staff_user_id: uuid.UUID) -> StaffDriveConnection | N provider=self.PROVIDER, ) + async def get_active_connection_or_raise( + self, + staff_user_id: uuid.UUID, + ) -> StaffDriveConnection: + connection = await self.get_status(staff_user_id) + if connection is None: + raise AppException.bad_request("Staff Google Drive is not connected") + return connection + + async def get_access_token_for_staff_user(self, staff_user_id: uuid.UUID) -> str: + connection = await self.get_active_connection_or_raise(staff_user_id) + return self.decrypt(connection.access_token) + async def disconnect(self, staff_user_id: uuid.UUID) -> None: connection = await self.get_status(staff_user_id) if connection is None: diff --git a/app/service/staged_upload_storage.py b/app/service/staged_upload_storage.py new file mode 100644 index 00000000..813fa2bf --- /dev/null +++ b/app/service/staged_upload_storage.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import uuid + +from app.core.exceptions import AppException +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME + + +@dataclass(frozen=True) +class StoredObject: + storage_key: str + content_type: str + file_name: str + + +@dataclass(frozen=True) +class PreviewObject: + data: bytes + content_type: str + file_name: str + + +class StagedUploadStorageService: + def __init__(self) -> None: + self.bucket = Bucket(IMAGES_BUCKET_NAME, "") + + @staticmethod + def build_staging_key( + *, + upload_request_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + ) -> str: + extension = Path(file_name).suffix.lower() + return f"staging/upload-requests/{upload_request_id}/{photo_id}{extension}" + + @staticmethod + def build_final_key( + *, + event_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + ) -> str: + extension = Path(file_name).suffix.lower() + return f"events/{event_id}/{photo_id}{extension}" + + async def store_staging_object( + self, + *, + upload_request_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + content_type: str, + data: bytes, + ) -> StoredObject: + storage_key = self.build_staging_key( + upload_request_id=upload_request_id, + photo_id=photo_id, + file_name=file_name, + ) + await self.bucket.put_bytes( + data=data, + object_name=storage_key, + content_type=content_type, + filename=file_name, + ) + return StoredObject( + storage_key=storage_key, + content_type=content_type, + file_name=file_name, + ) + + async def promote_to_final( + self, + *, + event_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + staging_storage_key: str, + ) -> str: + final_key = self.build_final_key( + event_id=event_id, + photo_id=photo_id, + file_name=file_name, + ) + await self.bucket.copy( + source_object_name=staging_storage_key, + target_object_name=final_key, + ) + return final_key + + async def delete_storage_key(self, storage_key: str) -> None: + try: + await self.bucket.delete(storage_key) + except Exception as exc: + raise AppException.storage_error("Failed to delete staged image from storage") from exc + + async def get_preview(self, storage_key: str) -> PreviewObject: + data, file_name, content_type = await self.bucket.get(storage_key) + return PreviewObject(data=data, file_name=file_name, content_type=content_type) diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index 6cac2384..83732a2e 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -1,12 +1,19 @@ from collections.abc import Sequence from dataclasses import dataclass +from collections import defaultdict +import json from typing import Literal import uuid from app.core.exceptions import AppException +from app.core.logger import logger +from app.infra.google_drive import GoogleDriveClient, GoogleDriveFileDownload +from app.infra.nats import NatsClient, NatsSubjects from sqlalchemy.exc import IntegrityError from app.schema.dto.staff.uploads import UploadPhotoInput +from app.service.staged_upload_storage import PreviewObject, StagedUploadStorageService +from app.service.staff_drive import StaffDriveService from app.service.staff_notifications import StaffNotificationsService from db.generated import photos as photo_queries from db.generated import upload_request_photos as upload_request_photo_queries @@ -26,22 +33,23 @@ class UploadRequestDetails: class UploadRequestsService: - _mime_type_extensions = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - } + _allowed_mime_types = {"image/jpeg", "image/png", "image/webp"} + _max_photo_size_bytes = 20 * 1024 * 1024 def __init__( self, upload_request_querier: upload_request_queries.AsyncQuerier, upload_request_photo_querier: upload_request_photo_queries.AsyncQuerier, photo_querier: photo_queries.AsyncQuerier, + staged_upload_storage: StagedUploadStorageService, + staff_drive_service: StaffDriveService, staff_notifications_service: StaffNotificationsService, ): self.upload_request_querier = upload_request_querier self.upload_request_photo_querier = upload_request_photo_querier self.photo_querier = photo_querier + self.staged_upload_storage = staged_upload_storage + self.staff_drive_service = staff_drive_service self.staff_notifications_service = staff_notifications_service @staticmethod @@ -52,14 +60,6 @@ def _status_value(status: object) -> str: def _role_value(role: object) -> str: return getattr(role, "value", str(role)) - def _build_staging_storage_key( - self, - upload_request_id: uuid.UUID, - photo: UploadPhotoInput, - ) -> str: - extension = self._mime_type_extensions[photo.mime_type] - return f"staging/upload-requests/{upload_request_id}/{uuid.uuid4()}{extension}" - @staticmethod def _raise_integrity_error(exc: IntegrityError) -> None: orig = getattr(exc, "orig", None) @@ -72,21 +72,203 @@ def _raise_integrity_error(exc: IntegrityError) -> None: raise AppException.internal_error("Failed to persist upload request") from exc - async def create_request( - self, - *, - event_id: uuid.UUID, - photos: Sequence[UploadPhotoInput], - requested_by: StaffUser, - ) -> UploadRequestDetails: + def _validate_downloaded_photo(self, downloaded_photo: GoogleDriveFileDownload) -> None: + metadata = downloaded_photo.metadata + if metadata.mime_type not in self._allowed_mime_types: + raise AppException.image_format_error("Unsupported image format from Google Drive") + if metadata.size_bytes <= 0 or metadata.size_bytes > self._max_photo_size_bytes: + raise AppException.bad_request("Google Drive image exceeds maximum allowed size") + + @staticmethod + def _validate_create_request_inputs(photos: Sequence[UploadPhotoInput]) -> None: if not photos: raise AppException.bad_request("At least one photo is required") if len(photos) > 20: raise AppException.bad_request("A batch can contain at most 20 photos") + drive_file_ids = [photo.drive_file_id for photo in photos] if len(drive_file_ids) != len(set(drive_file_ids)): raise AppException.conflict("Duplicate drive_file_id found in upload request batch") + async def _cleanup_created_photos(self, created_photos: Sequence[UploadRequestPhoto]) -> None: + for created_photo in created_photos: + try: + await self.staged_upload_storage.delete_storage_key(created_photo.staging_storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after create failure", + created_photo.staging_storage_key, + ) + + async def _cleanup_finalized_objects(self, storage_keys: Sequence[str]) -> None: + for storage_key in storage_keys: + try: + await self.staged_upload_storage.delete_storage_key(storage_key) + except Exception: + logger.warning( + "Failed to clean finalized object %s after approval failure", + storage_key, + ) + + async def _delete_staging_objects_best_effort( + self, + staged_photos: Sequence[UploadRequestPhoto], + ) -> None: + for staged_photo in staged_photos: + try: + await self.staged_upload_storage.delete_storage_key(staged_photo.staging_storage_key) + except Exception as exc: + logger.warning( + "Failed to delete staging object %s: %s", + staged_photo.staging_storage_key, + exc, + ) + + async def _list_request_photos_by_request_ids( + self, + request_ids: Sequence[uuid.UUID], + ) -> dict[uuid.UUID, list[UploadRequestPhoto]]: + photos_by_request_id: dict[uuid.UUID, list[UploadRequestPhoto]] = defaultdict(list) + if not request_ids: + return photos_by_request_id + + async for photo in self.upload_request_photo_querier.list_upload_request_photos_by_upload_request_ids( + upload_request_ids=list(request_ids) + ): + photos_by_request_id[photo.upload_request_id].append(photo) + + return photos_by_request_id + + async def _create_staged_photo( + self, + *, + upload_request_id: uuid.UUID, + photo: UploadPhotoInput, + access_token: str, + ) -> UploadRequestPhoto: + downloaded_photo = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=photo.drive_file_id, + ) + self._validate_downloaded_photo(downloaded_photo) + + stored_object = await self.staged_upload_storage.store_staging_object( + upload_request_id=upload_request_id, + photo_id=uuid.uuid4(), + file_name=downloaded_photo.metadata.name, + content_type=downloaded_photo.metadata.mime_type, + data=downloaded_photo.content, + ) + + try: + created_photo = await self.upload_request_photo_querier.create_upload_request_photo( + upload_request_id=upload_request_id, + drive_file_id=photo.drive_file_id, + file_name=downloaded_photo.metadata.name, + mime_type=downloaded_photo.metadata.mime_type, + size_bytes=downloaded_photo.metadata.size_bytes, + staging_storage_key=stored_object.storage_key, + taken_at=photo.taken_at, + day_number=photo.day_number, + visibility=photo.visibility, + status="staged", + ) + except IntegrityError: + try: + await self.staged_upload_storage.delete_storage_key(stored_object.storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after photo insert conflict", + stored_object.storage_key, + ) + raise + + if created_photo is None: + try: + await self.staged_upload_storage.delete_storage_key(stored_object.storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after empty photo insert result", + stored_object.storage_key, + ) + raise AppException.internal_error("Failed to create staged upload photo") + + return created_photo + + def _ensure_request_access( + self, + *, + current_staff_user: StaffUser, + upload_request: UploadRequest, + ) -> None: + if upload_request.requested_by == current_staff_user.id: + return + if self._role_value(current_staff_user.role) == StaffRole.MULTI_TEAM_LEAD.value: + return + raise AppException.forbidden("You are not allowed to access this upload request") + + async def _publish_event( + self, + *, + subject: NatsSubjects, + payload: dict[str, object], + ) -> None: + try: + await NatsClient.publish(subject, json.dumps(payload).encode("utf-8")) + except Exception as exc: + logger.warning("Failed to publish upload request event %s: %s", subject.value, exc) + + async def get_request_details( + self, + *, + request_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> UploadRequestDetails: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + return UploadRequestDetails( + request=upload_request, + photos=await self.list_request_photos(upload_request.id), + ) + + async def get_request_photo_preview( + self, + *, + request_id: uuid.UUID, + photo_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> PreviewObject: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + photo = await self.upload_request_photo_querier.get_upload_request_photo_by_id(id=photo_id) + if photo is None or photo.upload_request_id != request_id: + raise AppException.not_found("Upload request photo not found") + storage_key = photo.final_storage_key or photo.staging_storage_key + return await self.staged_upload_storage.get_preview(storage_key) + + async def create_request( + self, + *, + event_id: uuid.UUID, + photos: Sequence[UploadPhotoInput], + requested_by: StaffUser, + ) -> UploadRequestDetails: + self._validate_create_request_inputs(photos) + + access_token = await self.staff_drive_service.get_access_token_for_staff_user( + requested_by.id + ) + try: upload_request = await self.upload_request_querier.create_upload_request( event_id=event_id, @@ -102,19 +284,29 @@ async def create_request( created_photos: list[UploadRequestPhoto] = [] try: for photo in photos: - created_photo = await self.upload_request_photo_querier.create_upload_request_photo( - upload_request_id=upload_request.id, - drive_file_id=photo.drive_file_id, - staging_storage_key=self._build_staging_storage_key(upload_request.id, photo), - taken_at=photo.taken_at, - day_number=photo.day_number, - visibility=photo.visibility, + created_photos.append( + await self._create_staged_photo( + upload_request_id=upload_request.id, + photo=photo, + access_token=access_token, + ) ) - if created_photo is None: - raise AppException.internal_error("Failed to create staged upload photo") - created_photos.append(created_photo) except IntegrityError as exc: + await self._cleanup_created_photos(created_photos) self._raise_integrity_error(exc) + except Exception: + await self._cleanup_created_photos(created_photos) + raise + + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_CREATED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "requested_by": str(requested_by.id), + "photo_count": upload_request.photo_count, + }, + ) return UploadRequestDetails(request=upload_request, photos=created_photos) @@ -130,15 +322,23 @@ async def list_requests( requested_by = current_staff_user.id if scope == "my" else None - requests: list[UploadRequestDetails] = [] + request_rows: list[UploadRequest] = [] async for upload_request in self.upload_request_querier.list_upload_requests( requested_by=requested_by, status=status, ): + request_rows.append(upload_request) + + photos_by_request_id = await self._list_request_photos_by_request_ids( + [upload_request.id for upload_request in request_rows] + ) + + requests: list[UploadRequestDetails] = [] + for upload_request in request_rows: requests.append( UploadRequestDetails( request=upload_request, - photos=await self.list_request_photos(upload_request.id), + photos=photos_by_request_id.get(upload_request.id, []), ) ) return requests @@ -170,40 +370,68 @@ async def approve_request( if not staged_photos: raise AppException.bad_request("No staged photos found for this upload request") - upload_request = await self.upload_request_querier.approve_upload_request( - id=request_id, - approved_by=approved_by.id, - ) - if upload_request is None: - raise AppException.internal_error("Failed to approve upload request") + finalized_storage_keys: list[str] = [] + try: + for staged_photo in staged_photos: + final_storage_key = await self.staged_upload_storage.promote_to_final( + event_id=existing.event_id, + photo_id=staged_photo.id, + file_name=staged_photo.file_name, + staging_storage_key=staged_photo.staging_storage_key, + ) + finalized_storage_keys.append(final_storage_key) + created_photo = await self.photo_querier.create_photo( + event_id=existing.event_id, + storage_key=final_storage_key, + taken_at=staged_photo.taken_at, + day_number=staged_photo.day_number, + visibility=staged_photo.visibility, + ) + if created_photo is None: + raise AppException.internal_error("Failed to finalize staged photo") + updated_photo = await self.upload_request_photo_querier.update_upload_request_photo_approval( + id=staged_photo.id, + status="approved", + final_storage_key=final_storage_key, + ) + if updated_photo is None: + raise AppException.internal_error("Failed to update staged photo approval state") - for staged_photo in staged_photos: - created_photo = await self.photo_querier.create_photo( - event_id=upload_request.event_id, - storage_key=staged_photo.staging_storage_key, - taken_at=staged_photo.taken_at, - day_number=staged_photo.day_number, - visibility=staged_photo.visibility, + upload_request = await self.upload_request_querier.approve_upload_request( + id=request_id, + approved_by=approved_by.id, ) - if created_photo is None: - raise AppException.internal_error("Failed to finalize staged photo") - - await self.upload_request_photo_querier.delete_upload_request_photos_by_upload_request_id( - upload_request_id=request_id - ) + if upload_request is None: + raise AppException.internal_error("Failed to approve upload request") + await self.staff_notifications_service.create_notification( + staff_user_id=upload_request.requested_by, + type="upload_request_approved", + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "photo_count": upload_request.photo_count, + "approved_by": str(approved_by.id), + "status": "approved", + }, + ) + except Exception: + await self._cleanup_finalized_objects(finalized_storage_keys) + raise - await self.staff_notifications_service.create_notification( - staff_user_id=upload_request.requested_by, - type="upload_request_approved", + await self._delete_staging_objects_best_effort(staged_photos) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_APPROVED, payload={ "upload_request_id": str(upload_request.id), "event_id": str(upload_request.event_id), - "photo_count": upload_request.photo_count, "approved_by": str(approved_by.id), - "status": "approved", + "photo_count": upload_request.photo_count, }, ) - return UploadRequestDetails(request=upload_request, photos=[]) + return UploadRequestDetails( + request=upload_request, + photos=await self.list_request_photos(request_id), + ) async def reject_request( self, @@ -226,9 +454,13 @@ async def reject_request( if upload_request is None: raise AppException.internal_error("Failed to reject upload request") - await self.upload_request_photo_querier.delete_upload_request_photos_by_upload_request_id( - upload_request_id=request_id - ) + staged_photos = await self.list_request_photos(request_id) + rejected_photos: list[UploadRequestPhoto] = [] + async for staged_photo in self.upload_request_photo_querier.update_upload_request_photo_status_by_upload_request_id( + upload_request_id=request_id, + status="rejected", + ): + rejected_photos.append(staged_photo) await self.staff_notifications_service.create_notification( staff_user_id=upload_request.requested_by, @@ -242,4 +474,15 @@ async def reject_request( "reason": reason, }, ) - return UploadRequestDetails(request=upload_request, photos=[]) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_REJECTED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "approved_by": str(approved_by.id), + "photo_count": upload_request.photo_count, + "reason": reason, + }, + ) + await self._delete_staging_objects_best_effort(staged_photos) + return UploadRequestDetails(request=upload_request, photos=rejected_photos) diff --git a/db/generated/devices.py b/db/generated/devices.py index 2df5e9bc..d1b5b11e 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -13,7 +13,7 @@ COUNT__USER__DEVICES = """-- name: count__user__devices \\:one -SELECT COUNT(*) +SELECT COUNT(*) FROM user_devices WHERE user_id = :p1 """ diff --git a/db/generated/models.py b/db/generated/models.py index 7dca360d..07b95b8d 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -182,10 +182,15 @@ class UploadRequestPhoto: id: uuid.UUID upload_request_id: uuid.UUID drive_file_id: str + file_name: str + mime_type: str + size_bytes: int staging_storage_key: str + final_storage_key: Optional[str] taken_at: Optional[datetime.datetime] day_number: Optional[int] visibility: str + status: str created_at: datetime.datetime @@ -199,9 +204,6 @@ class User: display_name: Optional[str] face_embedding: Optional[Any] deleted_at: Optional[datetime.datetime] - display_name: Optional[str] - face_embedding: Optional[Any] - deleted_at: Optional[datetime.datetime] @dataclasses.dataclass() diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 1b679ac0..a3295980 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -13,21 +13,30 @@ INSERT INTO upload_request_photos ( upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, taken_at, day_number, - visibility + visibility, + status ) VALUES ( - :p1, :p2, :p3, :p4, :p5, :p6 + :p1, :p2, :p3, :p4, :p5, :p6, :p7, :p8, :p9, :p10 ) RETURNING id, upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, + final_storage_key, taken_at, day_number, visibility, + status, created_at """ @@ -37,10 +46,15 @@ id, upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, + final_storage_key, taken_at, day_number, visibility, + status, created_at FROM upload_request_photos WHERE upload_request_id = :p1 @@ -48,6 +62,90 @@ """ +LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS = """-- name: list_upload_request_photos_by_upload_request_ids \\:many +SELECT + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +FROM upload_request_photos +WHERE upload_request_id = ANY(:p1) +ORDER BY created_at ASC +""" + + +GET_UPLOAD_REQUEST_PHOTO_BY_ID = """-- name: get_upload_request_photo_by_id \\:one +SELECT + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +FROM upload_request_photos +WHERE id = :p1 +""" + + +UPDATE_UPLOAD_REQUEST_PHOTO_APPROVAL = """-- name: update_upload_request_photo_approval \\:one +UPDATE upload_request_photos +SET status = :p2, + final_storage_key = :p3 +WHERE id = :p1 +RETURNING + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +""" + + +UPDATE_UPLOAD_REQUEST_PHOTO_STATUS_BY_UPLOAD_REQUEST_ID = """-- name: update_upload_request_photo_status_by_upload_request_id \\:many +UPDATE upload_request_photos +SET status = :p2 +WHERE upload_request_id = :p1 +RETURNING + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +""" + + DELETE_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_ID = """-- name: delete_upload_request_photos_by_upload_request_id \\:exec DELETE FROM upload_request_photos WHERE upload_request_id = :p1 @@ -63,10 +161,14 @@ async def create_upload_request_photo( *, upload_request_id: uuid.UUID, drive_file_id: str, + file_name: str, + mime_type: str, + size_bytes: int, staging_storage_key: str, taken_at: datetime.datetime | None, day_number: int | None, visibility: str, + status: str, ) -> Optional[models.UploadRequestPhoto]: row = ( await self._conn.execute( @@ -74,10 +176,14 @@ async def create_upload_request_photo( { "p1": upload_request_id, "p2": drive_file_id, - "p3": staging_storage_key, - "p4": taken_at, - "p5": day_number, - "p6": visibility, + "p3": file_name, + "p4": mime_type, + "p5": size_bytes, + "p6": staging_storage_key, + "p7": taken_at, + "p8": day_number, + "p9": visibility, + "p10": status, }, ) ).first() @@ -97,6 +203,63 @@ async def list_upload_request_photos_by_upload_request_id( async for row in result: yield _row_to_upload_request_photo(row) + async def list_upload_request_photos_by_upload_request_ids( + self, + *, + upload_request_ids: list[uuid.UUID], + ) -> AsyncIterator[models.UploadRequestPhoto]: + statement = sqlalchemy.text(LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS).bindparams( + sqlalchemy.bindparam("p1", type_=sqlalchemy.ARRAY(sqlalchemy.Uuid)) + ) + result = await self._conn.stream(statement, {"p1": upload_request_ids}) + async for row in result: + yield _row_to_upload_request_photo(row) + + async def get_upload_request_photo_by_id( + self, + *, + id: uuid.UUID, + ) -> Optional[models.UploadRequestPhoto]: + row = ( + await self._conn.execute( + sqlalchemy.text(GET_UPLOAD_REQUEST_PHOTO_BY_ID), + {"p1": id}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_photo(row) + + async def update_upload_request_photo_approval( + self, + *, + id: uuid.UUID, + status: str, + final_storage_key: str | None, + ) -> Optional[models.UploadRequestPhoto]: + row = ( + await self._conn.execute( + sqlalchemy.text(UPDATE_UPLOAD_REQUEST_PHOTO_APPROVAL), + {"p1": id, "p2": status, "p3": final_storage_key}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_photo(row) + + async def update_upload_request_photo_status_by_upload_request_id( + self, + *, + upload_request_id: uuid.UUID, + status: str, + ) -> AsyncIterator[models.UploadRequestPhoto]: + result = await self._conn.stream( + sqlalchemy.text(UPDATE_UPLOAD_REQUEST_PHOTO_STATUS_BY_UPLOAD_REQUEST_ID), + {"p1": upload_request_id, "p2": status}, + ) + async for row in result: + yield _row_to_upload_request_photo(row) + async def delete_upload_request_photos_by_upload_request_id( self, *, @@ -115,9 +278,14 @@ def _row_to_upload_request_photo( id=row[0], upload_request_id=row[1], drive_file_id=row[2], - staging_storage_key=row[3], - taken_at=row[4], - day_number=row[5], - visibility=row[6], - created_at=row[7], + file_name=row[3], + mime_type=row[4], + size_bytes=row[5], + staging_storage_key=row[6], + final_storage_key=row[7], + taken_at=row[8], + day_number=row[9], + visibility=row[10], + status=row[11], + created_at=row[12], ) diff --git a/db/queries/staff_notifications.sql b/db/queries/staff_notifications.sql new file mode 100644 index 00000000..74070b81 --- /dev/null +++ b/db/queries/staff_notifications.sql @@ -0,0 +1,23 @@ +-- name: CreateStaffNotification :one +INSERT INTO staff_notifications ( + staff_user_id, + type, + payload +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: ListStaffNotificationsByStaffUserID :many +SELECT * +FROM staff_notifications +WHERE staff_user_id = $1 +ORDER BY created_at DESC; + +-- name: MarkStaffNotificationAsRead :one +UPDATE staff_notifications +SET read_at = NOW() +WHERE id = $1 + AND staff_user_id = $2 + AND read_at IS NULL +RETURNING *; diff --git a/db/queries/upload_request_photos.sql b/db/queries/upload_request_photos.sql new file mode 100644 index 00000000..bab46b5e --- /dev/null +++ b/db/queries/upload_request_photos.sql @@ -0,0 +1,50 @@ +-- name: CreateUploadRequestPhoto :one +INSERT INTO upload_request_photos ( + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + taken_at, + day_number, + visibility, + status +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 +) +RETURNING *; + +-- name: ListUploadRequestPhotosByUploadRequestID :many +SELECT * +FROM upload_request_photos +WHERE upload_request_id = $1 +ORDER BY created_at ASC; + +-- name: ListUploadRequestPhotosByUploadRequestIDs :many +SELECT * +FROM upload_request_photos +WHERE upload_request_id = ANY($1::uuid[]) +ORDER BY created_at ASC; + +-- name: GetUploadRequestPhotoByID :one +SELECT * +FROM upload_request_photos +WHERE id = $1; + +-- name: UpdateUploadRequestPhotoApproval :one +UPDATE upload_request_photos +SET status = $2, + final_storage_key = $3 +WHERE id = $1 +RETURNING *; + +-- name: UpdateUploadRequestPhotoStatusByUploadRequestID :many +UPDATE upload_request_photos +SET status = $2 +WHERE upload_request_id = $1 +RETURNING *; + +-- name: DeleteUploadRequestPhotosByUploadRequestID :exec +DELETE FROM upload_request_photos +WHERE upload_request_id = $1; diff --git a/migrations/sql/down/add-staff-upload-review-base.sql b/migrations/sql/down/add-staff-upload-review-base.sql new file mode 100644 index 00000000..6c2ae417 --- /dev/null +++ b/migrations/sql/down/add-staff-upload-review-base.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS upload_request_photos; + +ALTER TABLE upload_requests + DROP COLUMN IF EXISTS rejection_reason, + DROP COLUMN IF EXISTS photo_count; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM upload_requests + WHERE drive_file_id IS NULL + ) THEN + ALTER TABLE upload_requests + ALTER COLUMN drive_file_id SET NOT NULL; + END IF; +END $$; + +DROP TABLE IF EXISTS staff_notifications; diff --git a/migrations/sql/up/add-staff-upload-review-base.sql b/migrations/sql/up/add-staff-upload-review-base.sql new file mode 100644 index 00000000..ee45a2af --- /dev/null +++ b/migrations/sql/up/add-staff-upload-review-base.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS staff_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + staff_user_id UUID NOT NULL REFERENCES staff_users(id) ON DELETE CASCADE, + type VARCHAR(64) NOT NULL, + payload JSONB NOT NULL, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_staff_notifications_staff_user_id +ON staff_notifications(staff_user_id); + +CREATE INDEX IF NOT EXISTS idx_staff_notifications_read_at +ON staff_notifications(read_at); + +ALTER TABLE upload_requests + ALTER COLUMN drive_file_id DROP NOT NULL; + +ALTER TABLE upload_requests + ADD COLUMN IF NOT EXISTS photo_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +CREATE TABLE IF NOT EXISTS upload_request_photos ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + upload_request_id UUID NOT NULL REFERENCES upload_requests(id) ON DELETE CASCADE, + drive_file_id TEXT NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT 'unknown', + mime_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL DEFAULT 0, + staging_storage_key TEXT NOT NULL, + final_storage_key TEXT, + taken_at TIMESTAMPTZ, + day_number INT, + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + status VARCHAR(32) NOT NULL DEFAULT 'staged', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(upload_request_id, drive_file_id) +); + +CREATE INDEX IF NOT EXISTS idx_upload_request_photos_upload_request_id +ON upload_request_photos(upload_request_id); + +CREATE INDEX IF NOT EXISTS idx_upload_request_photos_status +ON upload_request_photos(status); diff --git a/migrations/versions/5ead72a95638_merge_alembic_heads.py b/migrations/versions/5ead72a95638_merge_alembic_heads.py index 2d22d7b4..4030d98a 100644 --- a/migrations/versions/5ead72a95638_merge_alembic_heads.py +++ b/migrations/versions/5ead72a95638_merge_alembic_heads.py @@ -1,7 +1,7 @@ """merge alembic heads Revision ID: 5ead72a95638 -Revises: a4b8c2d9e3f1, b2e532644368 +Revises: eed44c193b3d Create Date: 2026-03-15 14:24:04.545981 """ @@ -9,7 +9,7 @@ # revision identifiers, used by Alembic. revision: str = '5ead72a95638' -down_revision: Union[str, Sequence[str], None] = ('a4b8c2d9e3f1', 'b2e532644368') +down_revision: Union[str, Sequence[str], None] = 'eed44c193b3d' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/migrations/versions/989510311240_replace_staff_discord_with_password.py b/migrations/versions/989510311240_replace_staff_discord_with_password.py index c495b433..c2693615 100644 --- a/migrations/versions/989510311240_replace_staff_discord_with_password.py +++ b/migrations/versions/989510311240_replace_staff_discord_with_password.py @@ -9,9 +9,6 @@ from migrations.helper import run_sql_down, run_sql_up - - - # revision identifiers, used by Alembic. revision: str = '989510311240' down_revision: Union[str, Sequence[str], None] = '8e9b7a6c4d11' @@ -21,9 +18,6 @@ def upgrade() -> None: run_sql_up("replace-staff-discord-with-password") - - - def downgrade() -> None: diff --git a/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py b/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py new file mode 100644 index 00000000..421a4adc --- /dev/null +++ b/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py @@ -0,0 +1,25 @@ +"""add_staff_upload_review_base + +Revision ID: c3b8d0f1e2a4 +Revises: 5ead72a95638 +Create Date: 2026-03-19 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +revision: str = "c3b8d0f1e2a4" +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-staff-upload-review-base") + + +def downgrade() -> None: + run_sql_down("add-staff-upload-review-base") diff --git a/migrations/versions/e171e4e2247d_init_extension.py b/migrations/versions/e171e4e2247d_init_extension.py index abd29713..8031f267 100644 --- a/migrations/versions/e171e4e2247d_init_extension.py +++ b/migrations/versions/e171e4e2247d_init_extension.py @@ -1,7 +1,7 @@ """init_extension Revision ID: e171e4e2247d -Revises: +Revises: Create Date: 2026-02-28 13:58:27.732494 """ @@ -20,7 +20,5 @@ def upgrade() -> None: run_sql_up("init_extension") - - def downgrade() -> None: run_sql_down("init_extension")