"""Library resource application service.""" from __future__ import annotations from fastapi import HTTPException, status from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.api.schemas.library import ( ArchiveLibraryResourceResponse, CreateLibraryResourceRequest, LibraryResourceFileRead, LibraryResourceListResponse, LibraryResourceRead, PresignUploadResponse, UpdateLibraryResourceRequest, ) from app.config.settings import get_settings from app.domain.enums import LibraryFileRole, LibraryResourceStatus, LibraryResourceType from app.infra.db.models.library_resource import LibraryResourceORM from app.infra.db.models.library_resource_file import LibraryResourceFileORM from app.infra.storage.s3 import RESOURCE_PREFIXES, S3PresignService class LibraryService: """Application service for resource-library uploads and queries.""" def __init__(self, presign_service: S3PresignService | None = None) -> None: self.presign_service = presign_service or S3PresignService() def create_upload_presign( self, resource_type: LibraryResourceType, file_name: str, content_type: str, ) -> PresignUploadResponse: """Create upload metadata for a direct S3 PUT.""" settings = get_settings() if not all( [ settings.s3_bucket, settings.s3_region, settings.s3_access_key, settings.s3_secret_key, settings.s3_endpoint, ] ): raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="S3 upload settings are incomplete", ) storage_key, upload_url = self.presign_service.create_upload( resource_type=resource_type, file_name=file_name, content_type=content_type, ) return PresignUploadResponse( method="PUT", upload_url=upload_url, headers={"content-type": content_type}, storage_key=storage_key, public_url=self.presign_service.get_public_url(storage_key), ) async def create_resource( self, session: AsyncSession, payload: CreateLibraryResourceRequest, ) -> LibraryResourceRead: """Persist a resource and its uploaded file metadata.""" self._validate_payload(payload) resource = LibraryResourceORM( resource_type=payload.resource_type, name=payload.name.strip(), description=payload.description.strip() if payload.description else None, tags=[tag.strip() for tag in payload.tags if tag.strip()], status=LibraryResourceStatus.ACTIVE, gender=payload.gender.strip() if payload.gender else None, age_group=payload.age_group.strip() if payload.age_group else None, pose_id=payload.pose_id, environment=payload.environment.strip() if payload.environment else None, category=payload.category.strip() if payload.category else None, ) session.add(resource) await session.flush() file_models: list[LibraryResourceFileORM] = [] for item in payload.files: file_model = LibraryResourceFileORM( resource_id=resource.id, file_role=item.file_role, storage_key=item.storage_key, public_url=item.public_url, bucket=get_settings().s3_bucket, mime_type=item.mime_type, size_bytes=item.size_bytes, sort_order=item.sort_order, width=item.width, height=item.height, ) session.add(file_model) file_models.append(file_model) await session.flush() resource.original_file_id = self._find_role(file_models, LibraryFileRole.ORIGINAL).id resource.cover_file_id = self._find_role(file_models, LibraryFileRole.THUMBNAIL).id await session.commit() return self._to_read(resource, files=file_models) async def list_resources( self, session: AsyncSession, *, resource_type: LibraryResourceType | None = None, query: str | None = None, gender: str | None = None, age_group: str | None = None, environment: str | None = None, category: str | None = None, page: int = 1, limit: int = 20, ) -> LibraryResourceListResponse: """List persisted resources with simple filter support.""" filters = [LibraryResourceORM.status == LibraryResourceStatus.ACTIVE] if resource_type is not None: filters.append(LibraryResourceORM.resource_type == resource_type) if query: like = f"%{query.strip()}%" filters.append( or_( LibraryResourceORM.name.ilike(like), LibraryResourceORM.description.ilike(like), ) ) if gender: filters.append(LibraryResourceORM.gender == gender) if age_group: filters.append(LibraryResourceORM.age_group == age_group) if environment: filters.append(LibraryResourceORM.environment == environment) if category: filters.append(LibraryResourceORM.category == category) total = ( await session.execute(select(func.count(LibraryResourceORM.id)).where(*filters)) ).scalar_one() result = await session.execute( select(LibraryResourceORM) .options(selectinload(LibraryResourceORM.files)) .where(*filters) .order_by(LibraryResourceORM.created_at.desc(), LibraryResourceORM.id.desc()) .offset((page - 1) * limit) .limit(limit) ) items = result.scalars().all() return LibraryResourceListResponse(total=total, items=[self._to_read(item) for item in items]) async def update_resource( self, session: AsyncSession, resource_id: int, payload: UpdateLibraryResourceRequest, ) -> LibraryResourceRead: """Update editable metadata on a library resource.""" resource = await self._get_resource_or_404(session, resource_id) if payload.name is not None: name = payload.name.strip() if not name: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Resource name is required", ) resource.name = name if payload.description is not None: resource.description = payload.description.strip() or None if payload.tags is not None: resource.tags = [tag.strip() for tag in payload.tags if tag.strip()] if resource.resource_type == LibraryResourceType.MODEL: if payload.gender is not None: resource.gender = payload.gender.strip() or None if payload.age_group is not None: resource.age_group = payload.age_group.strip() or None if payload.pose_id is not None: resource.pose_id = payload.pose_id elif resource.resource_type == LibraryResourceType.SCENE: if payload.environment is not None: resource.environment = payload.environment.strip() or None elif resource.resource_type == LibraryResourceType.GARMENT: if payload.category is not None: resource.category = payload.category.strip() or None if payload.cover_file_id is not None: cover_file = next( (file for file in resource.files if file.id == payload.cover_file_id), None, ) if cover_file is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cover file does not belong to the resource", ) resource.cover_file_id = cover_file.id self._validate_existing_resource(resource) await session.commit() await session.refresh(resource, attribute_names=["files"]) return self._to_read(resource) async def archive_resource( self, session: AsyncSession, resource_id: int, ) -> ArchiveLibraryResourceResponse: """Soft delete a library resource by archiving it.""" resource = await self._get_resource_or_404(session, resource_id) resource.status = LibraryResourceStatus.ARCHIVED await session.commit() return ArchiveLibraryResourceResponse(id=resource.id) def _validate_payload(self, payload: CreateLibraryResourceRequest) -> None: if not payload.name.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Resource name is required") if not payload.files: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="At least one file is required") originals = [file for file in payload.files if file.file_role == LibraryFileRole.ORIGINAL] thumbnails = [file for file in payload.files if file.file_role == LibraryFileRole.THUMBNAIL] if len(originals) != 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Exactly one original file is required", ) if len(thumbnails) != 1: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Exactly one thumbnail file is required", ) expected_prefix = f"library/{RESOURCE_PREFIXES[payload.resource_type]}/" for file in payload.files: if not file.storage_key.startswith(expected_prefix): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file key does not match resource type", ) if payload.resource_type == LibraryResourceType.MODEL: if not payload.gender or not payload.age_group: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Model resources require gender and age_group", ) elif payload.resource_type == LibraryResourceType.SCENE: if not payload.environment: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Scene resources require environment", ) elif payload.resource_type == LibraryResourceType.GARMENT and not payload.category: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Garment resources require category", ) def _find_role( self, files: list[LibraryResourceFileORM], role: LibraryFileRole, ) -> LibraryResourceFileORM: return next(file for file in files if file.file_role == role) def _to_read( self, resource: LibraryResourceORM, *, files: list[LibraryResourceFileORM] | None = None, ) -> LibraryResourceRead: resource_files = files if files is not None else list(resource.files) files_sorted = sorted(resource_files, key=lambda item: (item.sort_order, item.id)) cover = next((item for item in files_sorted if item.id == resource.cover_file_id), None) original = next((item for item in files_sorted if item.id == resource.original_file_id), None) return LibraryResourceRead( id=resource.id, resource_type=resource.resource_type, name=resource.name, description=resource.description, tags=resource.tags, status=resource.status, gender=resource.gender, age_group=resource.age_group, pose_id=resource.pose_id, environment=resource.environment, category=resource.category, cover_url=cover.public_url if cover else None, original_url=original.public_url if original else None, files=[ LibraryResourceFileRead( id=file.id, file_role=file.file_role, storage_key=file.storage_key, public_url=file.public_url, bucket=file.bucket, mime_type=file.mime_type, size_bytes=file.size_bytes, sort_order=file.sort_order, width=file.width, height=file.height, created_at=file.created_at, ) for file in files_sorted ], created_at=resource.created_at, updated_at=resource.updated_at, ) async def _get_resource_or_404( self, session: AsyncSession, resource_id: int, ) -> LibraryResourceORM: result = await session.execute( select(LibraryResourceORM) .options(selectinload(LibraryResourceORM.files)) .where(LibraryResourceORM.id == resource_id) ) resource = result.scalar_one_or_none() if resource is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Resource not found") return resource def _validate_existing_resource(self, resource: LibraryResourceORM) -> None: if not resource.name.strip(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Resource name is required") if resource.resource_type == LibraryResourceType.MODEL: if not resource.gender or not resource.age_group: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Model resources require gender and age_group", ) elif resource.resource_type == LibraryResourceType.SCENE: if not resource.environment: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Scene resources require environment", ) elif resource.resource_type == LibraryResourceType.GARMENT and not resource.category: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Garment resources require category", )