diff --git a/src/background_jobs/photo.py b/src/background_jobs/photo.py index 08f9c93..55122ab 100644 --- a/src/background_jobs/photo.py +++ b/src/background_jobs/photo.py @@ -16,4 +16,4 @@ async def resize_photo(path: str, filename: str, photo_id: int): async def generate_thumbnail(path: str, filename: str): editor = PhotoEditor(path, filename) editor.resize(new_width=300) - editor.write_to_file(quality=85, dest_path=f"{path}/thumbs/") \ No newline at end of file + editor.write_to_file(quality=85, dest_path=f"{path}/thumbs/") diff --git a/src/endpoints/photo_editor_preview.py b/src/endpoints/photo_editor_preview.py index 2564719..f4a7438 100644 --- a/src/endpoints/photo_editor_preview.py +++ b/src/endpoints/photo_editor_preview.py @@ -19,7 +19,7 @@ class PhotoEditorEndpoint(AuthEndpoint): basepath = get_photo_basepath(photo.flight_id) filename = photo.filename - original_filename = '_original_'+filename + original_filename = '_original_' + filename if os.path.exists(f"{basepath}/{original_filename}"): filename = original_filename @@ -47,7 +47,4 @@ class PhotoEditorEndpoint(AuthEndpoint): if adjustments: editor.adjust(**adjustments) - - return StreamingResponse(content=editor.get_as_stream(), media_type="image/jpeg") - diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index b4a41f1..98168aa 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional import strawberry from database import models from decorators.endpoints import authenticated_user_only @@ -15,8 +15,8 @@ class CopilotQueries: return await BaseQueryResolver(Copilot, models.Copilot).get_list(info.context.user_id) @strawberry.field() - @authenticated_user_only() - async def copilot(root, info, id: int) -> Copilot: + async def copilot(root, info, id: int, pilot_username: Optional[str] = None) -> Copilot: + params = {} return await BaseQueryResolver(Copilot, models.Copilot).get_one(id, user_id=info.context.user_id) diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index e6c8021..1092eb7 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -1,24 +1,10 @@ -import asyncio -import os -import shutil -from typing import List, Optional +from typing import List import strawberry -from PIL import Image -from sqlalchemy import delete, select - -from background_jobs.elevation import add_terrain_elevation_to_photo -from background_jobs.photo import generate_thumbnail, resize_photo from database import models from decorators.endpoints import authenticated_user_only -from database.transaction import get_session -from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver +from graphql_schema.entities.resolvers.base import BaseQueryResolver from graphql_schema.entities.resolvers.photo import PhotoMutationResolver -from graphql_schema.entities.types.types import Photo, PhotoAdjustment -from logger import log -from paths import get_photo_basepath -from utils.file import delete_file -from utils.image import parse_exif_info, rotate_image_no_crop, adjust_image, resize_image, PhotoEditor -from utils.upload import handle_file_upload +from graphql_schema.entities.types.types import Photo from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput @@ -38,36 +24,7 @@ class PhotoMutation: @strawberry.mutation @authenticated_user_only() async def upload_photo(self, info, input: UploadPhotoInput) -> Photo: - path = get_photo_basepath(input.flight_id) - filename = await handle_file_upload(input.photo, path) - exif_info = await parse_exif_info(path, filename) - - img = Image.open(f"{path}/{filename}") - - photo = await PhotoMutationResolver().create( - user_id=info.context.user_id, - data={ - "flight_id": input.flight_id, - "name": input.name, - "filename": filename, - "width": img.width, - "height": img.height, - "description": input.description, - "exposed_at": exif_info.get("datetime_original"), - "gps_latitude": exif_info.get("gps_latitude"), - "gps_longitude": exif_info.get("gps_longitude"), - "gps_altitude": exif_info.get("gps_altitude"), - "is_flight_cover": False, - }, - ) - - info.context.background_tasks.add_task(resize_photo, path=path, filename=filename, photo_id=photo.id) - info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename) - - if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): - info.context.background_tasks.add_task(add_terrain_elevation_to_photo, photo=photo) - - return photo + return await PhotoMutationResolver().upload(info, input) @strawberry.mutation() @authenticated_user_only() @@ -77,108 +34,19 @@ class PhotoMutation: @strawberry.mutation() @authenticated_user_only() async def change_orientation(self, info, id: int, direction: str) -> Photo: - async with get_session() as db: - photo = (await db.scalars( - BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) - )).one() - flight_id = photo.flight_id - photo_id = photo.id - photo_filename = photo.filename - - photo_path = get_photo_basepath(flight_id) - original_filename = "_original_" + photo_filename - if not os.path.isfile(photo_path + "/" + original_filename): - shutil.copyfile(photo_path + "/" + photo_filename, photo_path + "/" + original_filename) - - degrees_map = { - "clockwise": -90, - "counterClockwise": 90 - } - - # rotate original - editor = PhotoEditor(photo_path, original_filename) - editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) - editor.write_to_file(quality=100) - - # rotate possibly adjusted image - editor = PhotoEditor(photo_path, photo_filename) - editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) - editor.write_to_file(quality=100) - - info.context.background_tasks.add_task(generate_thumbnail, path=photo_path, filename=photo_filename) - - async with get_session() as db: - photo = await models.Photo.update(db, { - "width": editor.img.width, - "height": editor.img.height - }, id=photo_id) - return Photo(**photo.as_dict()) + return await PhotoMutationResolver().change_orientation( + id=id, + user_id=info.context.user_id, + direction=direction, + info=info + ) @strawberry.mutation() @authenticated_user_only() async def adjust_photo(self, info, id: int, adjustment: AdjustmentInput) -> Photo: - async with get_session() as db: - photo = (await db.scalars( - BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) - )).one() - flight_id = photo.flight_id - photo_filename = photo.filename - photo_as_dict = photo.as_dict() - - photo_path = get_photo_basepath(flight_id) - - # TOOD: presunout do samostatne metody - original_filename = "_original_" + photo_filename - if not os.path.isfile(photo_path + "/" + original_filename): - shutil.copyfile(photo_path + "/" + photo_filename, photo_path + "/" + original_filename) - - editor = ( - PhotoEditor(photo_path, original_filename) - .adjust( - brightness=adjustment.brightness, - contrast=adjustment.contrast, - sharpness=adjustment.sharpness, - saturation=adjustment.saturation - ) - ) - - if adjustment.rotate: - rotate_angle = adjustment.rotate - editor.rotate(rotate_angle, adjustment.crop_after_rotate) - - if adjustment.crop: - editor.crop(**adjustment.crop.to_dict()) - - editor.write_to_file(dest_filename=photo_filename) - - info.context.background_tasks.add_task(generate_thumbnail, path=photo_path, filename=photo_filename) - - async with get_session() as db: - await db.execute(delete(models.PhotoAdjustment).filter(models.PhotoAdjustment.photo_id == id)) - - crop_info = {"crop_" + key: value for key, value in adjustment.crop.to_dict().items()} if adjustment.crop else {} - await models.PhotoAdjustment.create(db, { - "photo_id": id, - "contrast": adjustment.contrast, - "saturation": adjustment.saturation, - "brightness": adjustment.brightness, - "rotate": adjustment.rotate, - "sharpness": adjustment.sharpness, - **crop_info - }) - - width, height = editor.img_size - await models.Photo.update(db, {"width": width, "height": height}, id=id) - - return Photo(**photo_as_dict) + return await PhotoMutationResolver().adjust(id, info=info, user_id=info.context.user_id, adjustment=adjustment) @strawberry.mutation() @authenticated_user_only() async def delete_photo(self, info, id: int) -> Photo: - photo = await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id) - - base_path = get_photo_basepath(photo.flight_id) - delete_file(f"{base_path}/{photo.filename}", silent=True) - delete_file(f"{base_path}/thumbs/{photo.filename}", silent=True) - - return photo + return await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id) diff --git a/src/graphql_schema/entities/resolvers/photo.py b/src/graphql_schema/entities/resolvers/photo.py index 1ee5895..2b72387 100644 --- a/src/graphql_schema/entities/resolvers/photo.py +++ b/src/graphql_schema/entities/resolvers/photo.py @@ -1,24 +1,97 @@ -from sqlalchemy import update +import os +import shutil +from PIL import Image +from pydantic import BaseModel +from sqlalchemy import update, delete from sqlalchemy.ext.asyncio import AsyncSession +from background_jobs.elevation import add_terrain_elevation_to_photo +from background_jobs.photo import generate_thumbnail, resize_photo from database import models from database.transaction import get_session from graphql_schema.entities.helpers.combobox import handle_combobox_save from graphql_schema.entities.resolvers.base import BaseMutationResolver -from graphql_schema.entities.types.mutation_input import EditPhotoInput +from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput from graphql_schema.entities.types.types import Photo +from paths import get_photo_basepath +from utils.file import delete_file +from utils.image import PhotoEditor, parse_exif_info +from utils.upload import handle_file_upload + + +class PhotoDetailInfo(BaseModel): + filename: str + original_filename: str + path: str + flight_id: int class PhotoMutationResolver(BaseMutationResolver): def __init__(self): super().__init__(Photo, models.Photo) - async def reset_flight_cover(self, db: AsyncSession, flight_id: int, ignored_photo_id: int): + @staticmethod + async def _reset_flight_cover(db: AsyncSession, flight_id: int, ignored_photo_id: int): (await db.execute( update(models.Photo) .filter(models.Photo.flight_id == flight_id) - .filter(models.Photo.id != ignored_photo_id).values(is_flight_cover=False)) + .filter(models.Photo.id != ignored_photo_id) + .values(is_flight_cover=False)) ) + @staticmethod + def _copy_original(path: str, filename: str): + original_filename = "_original_" + filename + if not os.path.isfile(path + "/" + original_filename): + shutil.copyfile(path + "/" + filename, path + "/" + original_filename) + + return original_filename + + async def _get_photo_details(self, id: int, user_id: int): + async with get_session() as db: + photo = await self._get_one(db, id, created_by_id=user_id) + flight_id = photo.flight_id + filename = photo.filename + + path = get_photo_basepath(flight_id) + return PhotoDetailInfo( + flight_id=flight_id, + path=path, + filename=filename, + original_filename=self._copy_original(path, filename) + ) + + + async def upload(self, info, input: UploadPhotoInput) -> Photo: + path = get_photo_basepath(input.flight_id) + filename = await handle_file_upload(input.photo, path) + exif_info = await parse_exif_info(path, filename) + + img = Image.open(f"{path}/{filename}") + photo = await PhotoMutationResolver().create( + user_id=info.context.user_id, + data={ + "flight_id": input.flight_id, + "name": input.name, + "filename": filename, + "width": img.width, + "height": img.height, + "description": input.description, + "exposed_at": exif_info.get("datetime_original"), + "gps_latitude": exif_info.get("gps_latitude"), + "gps_longitude": exif_info.get("gps_longitude"), + "gps_altitude": exif_info.get("gps_altitude"), + "is_flight_cover": False, + }, + ) + + info.context.background_tasks.add_task(resize_photo, path=path, filename=filename, photo_id=photo.id) + info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename) + + if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): + info.context.background_tasks.add_task(add_terrain_elevation_to_photo, photo=photo) + + return photo + async def update(self, id: int, input: EditPhotoInput, user_id: int) -> Photo: data = input.to_dict() @@ -35,6 +108,85 @@ class PhotoMutationResolver(BaseMutationResolver): if input.is_flight_cover: # reset other covers - await self.reset_flight_cover(db, photo.flight_id, id) + await self._reset_flight_cover(db, photo.flight_id, id) return await self._do_update(db, obj=photo, data=data) + + async def change_orientation(self, id: int, user_id: int, direction: str, info): + photo = await self._get_photo_details(id, user_id) + + degrees_map = { + "clockwise": -90, + "counterClockwise": 90 + } + + # rotate original + editor = PhotoEditor(photo.path, photo.original_filename) + editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) + editor.write_to_file(quality=100) + + # rotate possibly adjusted image + editor = PhotoEditor(photo.path, photo.filename) + editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False) + editor.write_to_file(quality=100) + + info.context.background_tasks.add_task(generate_thumbnail, path=photo.path, filename=photo.filename) + + async with get_session() as db: + return await self._do_update(db, obj={"id": id}, data={ + "width": editor.img.width, + "height": editor.img.height + }) + + async def adjust(self, id: int, user_id: int, adjustment: AdjustmentInput, info): + photo = await self._get_photo_details(id, user_id) + editor = ( + PhotoEditor(photo.path, photo.original_filename) + .adjust( + brightness=adjustment.brightness, + contrast=adjustment.contrast, + sharpness=adjustment.sharpness, + saturation=adjustment.saturation + ) + ) + + if adjustment.rotate: + rotate_angle = adjustment.rotate + editor.rotate(rotate_angle, adjustment.crop_after_rotate) + + if adjustment.crop: + editor.crop(**adjustment.crop.to_dict()) + + editor.write_to_file(dest_filename=photo.filename) + + info.context.background_tasks.add_task(generate_thumbnail, path=photo.path, filename=photo.filename) + + async with get_session() as db: + await db.execute(delete(models.PhotoAdjustment).filter(models.PhotoAdjustment.photo_id == id)) + + crop_info = {"crop_" + key: value for key, value in adjustment.crop.to_dict().items()} if adjustment.crop else {} + await models.PhotoAdjustment.create(db, { + "photo_id": id, + "contrast": adjustment.contrast, + "saturation": adjustment.saturation, + "brightness": adjustment.brightness, + "rotate": adjustment.rotate, + "sharpness": adjustment.sharpness, + **crop_info + }) + + return await self._do_update(db, obj={"id": id}, data={ + "width": editor.img.width, + "height": editor.img.height + }) + + async def delete(self, user_id: int, id: int) -> Photo: + photo = await super().delete(user_id, id) + + base_path = get_photo_basepath(photo.flight_id) + delete_file(f"{base_path}/{photo.filename}", silent=True) + delete_file(f"{base_path}/_original_{photo.filename}", silent=True) + delete_file(f"{base_path}/thumbs/{photo.filename}", silent=True) + + return photo + diff --git a/src/graphql_schema/entities/types/base.py b/src/graphql_schema/entities/types/base.py index 7fce6f1..a01d5fa 100644 --- a/src/graphql_schema/entities/types/base.py +++ b/src/graphql_schema/entities/types/base.py @@ -10,7 +10,7 @@ class BaseGraphqlInputType: dict_data = {} for key in self.__annotations__.keys(): value = getattr(self, key) - if value is None or key in self.ignored_fields + self.base_ignored_fields: + if value is None or key in (self.ignored_fields + self.base_ignored_fields): continue if isinstance(value, datetime): diff --git a/src/utils/image.py b/src/utils/image.py index 207c951..bccffe7 100644 --- a/src/utils/image.py +++ b/src/utils/image.py @@ -136,7 +136,6 @@ class PhotoEditor: return dest - # ----------- # odsud niz to bude vse asi na smazani, vse by mela umet trida PhotoEditor