from typing import Optional, cast import io import strawberry from strawberry import Info from strawberry.file_uploads import Upload from strawberry.relay import GlobalID from channels.db import database_sync_to_async from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from core.models.session import ServiceSession, ProjectSession from core.models.session_image import ServiceSessionImage, ProjectSessionImage from core.graphql.types.session_image import ( ServiceSessionImageType, ProjectSessionImageType, ) from core.graphql.inputs.session_image import ( ServiceSessionImageUpdateInput, ProjectSessionImageUpdateInput, ) from core.graphql.utils import update_object, delete_object, _decode_global_id from core.services.events import ( publish_session_image_uploaded, publish_session_image_updated, publish_session_image_deleted, publish_session_media_internal_flagged, ) def _verify_image_bytes(data: bytes) -> None: """ Verify the uploaded bytes are a valid image payload using Pillow. Uses a safe import for UnidentifiedImageError for broader compatibility. """ from PIL import Image as PilImage try: from PIL import UnidentifiedImageError as _UIE # type: ignore except (ImportError, AttributeError): _UIE = None invalid_img_exc = (_UIE, OSError, ValueError) if _UIE else (OSError, ValueError) try: PilImage.open(io.BytesIO(data)).verify() except invalid_img_exc: raise ValidationError("Uploaded file is not a valid image.") @strawberry.type class Mutation: @strawberry.mutation(description="Upload an image to a ServiceSession") async def upload_service_session_image( self, info: Info, session_id: GlobalID, file: Upload, title: Optional[str] = None, notes: Optional[str] = None, internal: bool = True, ) -> ServiceSessionImageType: req_profile = getattr(info.context.request, "profile", None) if not req_profile: raise ValidationError("Authentication required.") if not file or not getattr(file, "filename", None): raise ValidationError("No file provided.") filename: str = file.filename content_type: str = getattr(file, "content_type", "") or "" data = await file.read() if not data: raise ValidationError("Empty file upload.") _verify_image_bytes(data) sess_pk = _decode_global_id(session_id) def _create_img_sync() -> ServiceSessionImage: sess = ServiceSession.objects.get(pk=sess_pk) img = ServiceSessionImage( title=title or "", notes=notes or "", service_session=sess, uploaded_by_team_profile=req_profile, content_type=content_type, internal=internal, ) img.image.save(filename, ContentFile(data), save=True) return img instance: ServiceSessionImage = await database_sync_to_async(_create_img_sync)() # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_uploaded( image_id=str(instance.id), session_id=str(instance.service_session_id), is_internal=internal, triggered_by=profile ) return cast(ServiceSessionImageType, instance) @strawberry.mutation(description="Upload an image to a ProjectSession") async def upload_project_session_image( self, info: Info, session_id: GlobalID, file: Upload, title: Optional[str] = None, notes: Optional[str] = None, internal: bool = True, ) -> ProjectSessionImageType: req_profile = getattr(info.context.request, "profile", None) if not req_profile: raise ValidationError("Authentication required.") if not file or not getattr(file, "filename", None): raise ValidationError("No file provided.") filename: str = file.filename content_type: str = getattr(file, "content_type", "") or "" data = await file.read() if not data: raise ValidationError("Empty file upload.") _verify_image_bytes(data) sess_pk = _decode_global_id(session_id) def _create_img_sync() -> ProjectSessionImage: sess = ProjectSession.objects.get(pk=sess_pk) img = ProjectSessionImage( title=title or "", notes=notes or "", project_session=sess, uploaded_by_team_profile=req_profile, content_type=content_type, internal=internal, ) img.image.save(filename, ContentFile(data), save=True) return img instance: ProjectSessionImage = await database_sync_to_async(_create_img_sync)() # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_uploaded( image_id=str(instance.id), session_id=str(instance.project_session_id), is_internal=internal, triggered_by=profile ) return cast(ProjectSessionImageType, instance) @strawberry.mutation(description="Update an existing ServiceSession image (e.g., title)") async def update_service_session_image( self, info: Info, input: ServiceSessionImageUpdateInput ) -> ServiceSessionImageType: payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal} instance = await update_object(payload, ServiceSessionImage) # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_updated( image_id=str(instance.id), session_id=str(instance.service_session_id), triggered_by=profile ) return cast(ServiceSessionImageType, instance) @strawberry.mutation(description="Update an existing ProjectSession image (e.g., title)") async def update_project_session_image( self, info: Info, input: ProjectSessionImageUpdateInput ) -> ProjectSessionImageType: payload = {"id": input.id, "title": input.title, "notes": input.notes, "internal": input.internal} instance = await update_object(payload, ProjectSessionImage) # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_updated( image_id=str(instance.id), session_id=str(instance.project_session_id), triggered_by=profile ) return cast(ProjectSessionImageType, instance) @strawberry.mutation(description="Delete a ServiceSession image") async def delete_service_session_image(self, info: Info, id: strawberry.ID) -> strawberry.ID: # Delete the instance (delete_object returns the instance before deletion) instance = await delete_object(id, ServiceSessionImage) if not instance: raise ValueError(f"ServiceSessionImage with ID {id} does not exist") # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_deleted( image_id=str(instance.id), session_id=str(instance.service_session_id), triggered_by=profile ) return id @strawberry.mutation(description="Delete a ProjectSession image") async def delete_project_session_image(self, info: Info, id: strawberry.ID) -> strawberry.ID: # Delete the instance (delete_object returns the instance before deletion) instance = await delete_object(id, ProjectSessionImage) if not instance: raise ValueError(f"ProjectSessionImage with ID {id} does not exist") # Publish events profile = getattr(info.context.request, 'profile', None) await publish_session_image_deleted( image_id=str(instance.id), session_id=str(instance.project_session_id), triggered_by=profile ) return id