From 400ad8b75af89dfeea6904417f57ad8dd2eccde7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Thu, 7 Mar 2024 06:45:00 +0100 Subject: [PATCH] Moznost nahravat fotky copilotem --- ...d_flight_has_copilot_token_7dd139549851.py | 53 ++++++++++++++ src/database/models.py | 2 + src/decorators/endpoints.py | 8 ++- src/graphql_schema/dataloaders/base.py | 69 ++++++++++++++----- .../dataloaders/multi_models.py | 9 ++- src/graphql_schema/entities/copilot.py | 15 +++- src/graphql_schema/entities/flight.py | 2 + src/graphql_schema/entities/photo.py | 21 ++++-- src/graphql_schema/entities/resolvers/base.py | 2 +- .../entities/resolvers/copilot.py | 15 ++++ .../entities/resolvers/flight.py | 7 +- .../entities/resolvers/photo.py | 68 ++++++++++++++---- .../entities/types/mutation_input.py | 1 + src/graphql_schema/entities/types/types.py | 7 ++ src/utils/str_utils.py | 7 ++ 15 files changed, 242 insertions(+), 44 deletions(-) create mode 100644 alembic/versions/20240302-150226_add_flight_has_copilot_token_7dd139549851.py create mode 100644 src/utils/str_utils.py diff --git a/alembic/versions/20240302-150226_add_flight_has_copilot_token_7dd139549851.py b/alembic/versions/20240302-150226_add_flight_has_copilot_token_7dd139549851.py new file mode 100644 index 0000000..3fc281d --- /dev/null +++ b/alembic/versions/20240302-150226_add_flight_has_copilot_token_7dd139549851.py @@ -0,0 +1,53 @@ +"""add flight_has_copilot.token + +Revision ID: 7dd139549851 +Revises: a410455fb3bb +Create Date: 2024-03-02 15:02:26.594830 + +""" +import random +import string + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision = '7dd139549851' +down_revision = 'a410455fb3bb' +branch_labels = None +depends_on = None + + +def rand_str(): + alphabet = string.ascii_letters + string.digits + return ''.join([random.choice(alphabet) for _ in range(64)]) + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight_has_copilot', sa.Column('token', sa.String(length=64), nullable=True)) + + conn = op.get_bind() + data = conn.execute(text("SELECT flight_id, copilot_id FROM flight_has_copilot")) + for flight_id, copilot_id in data: + print(flight_id, copilot_id) + conn.execute(text( + f"UPDATE flight_has_copilot SET token='{rand_str()}'" + f" WHERE flight_id={flight_id} AND copilot_id={copilot_id}" + )) + + + op.create_unique_constraint(None, 'flight_has_copilot', ['token']) + op.add_column('photo', sa.Column('created_by_copilot_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'photo', 'copilot', ['created_by_copilot_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('photo_ibfk_5', 'photo', type_='foreignkey') + op.drop_column('photo', 'created_by_copilot_id') + op.drop_constraint('token', 'flight_has_copilot', type_='unique') + op.drop_column('flight_has_copilot', 'token') + # ### end Alembic commands ### diff --git a/src/database/models.py b/src/database/models.py index c316610..ecd1e11 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -55,6 +55,7 @@ flight_has_copilot = Table( BaseModel.metadata, Column("flight_id", ForeignKey("flight.id"), primary_key=True), Column("copilot_id", ForeignKey("copilot.id"), primary_key=True), + Column("token", String(64), unique=True) ) copilot_has_photo = Table( @@ -141,6 +142,7 @@ class Photo(BaseModel): point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True) flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_by_copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id]) diff --git a/src/decorators/endpoints.py b/src/decorators/endpoints.py index 71a6bed..8c5b0b9 100644 --- a/src/decorators/endpoints.py +++ b/src/decorators/endpoints.py @@ -3,6 +3,10 @@ from fastapi import HTTPException from starlette.status import HTTP_401_UNAUTHORIZED +def raise_unauthorized(): + raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized") + + def allow_public(func): @wraps(func) async def decorator(*args, **kwargs): @@ -10,7 +14,7 @@ def allow_public(func): user_id = kwargs['info'].context.user_id public = kwargs.get('public') if not user_id and not public: - raise HTTPException(HTTP_401_UNAUTHORIZED) + raise_unauthorized() return await func(*args, **kwargs) @@ -24,7 +28,7 @@ def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_u if 'info' in kwargs: if not kwargs['info'].context.user_id: if raise_when_unauthorized: - raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized") + raise_unauthorized() else: return return_value_unauthorized return await func(*args, **kwargs) diff --git a/src/graphql_schema/dataloaders/base.py b/src/graphql_schema/dataloaders/base.py index 4239898..cfe09e7 100644 --- a/src/graphql_schema/dataloaders/base.py +++ b/src/graphql_schema/dataloaders/base.py @@ -1,5 +1,6 @@ from collections import defaultdict from typing import Type, List, Optional +from logger import log from database import models, async_session from database.query_builder import QueryBuilder @@ -9,7 +10,7 @@ class BaseDataloader: self, model: Type[models.BaseModel], relationship_column, filters: Optional[list] = None - ) -> None: + ): super().__init__() self.model = model self.query_builder = QueryBuilder(self.model) @@ -43,6 +44,7 @@ class MultiModelsDataloader(BaseDataloader): model: Type[models.BaseModel], relationship_column=None, filters: Optional[list] = None, + extra_select: Optional[list] = None, extra_join: Optional[list] = None, order_by: Optional[list] = None, ): @@ -52,31 +54,60 @@ class MultiModelsDataloader(BaseDataloader): extra_join = [] self.extra_join = extra_join + if extra_select is None: + extra_select = [] + self.extra_select = extra_select + if order_by is None: order_by = [model.id.desc()] # defaultne radit od nejnovejsich zaznamu self.order_by = order_by - async def load(self, ids: List[int]): - async with async_session() as db: - query = ( - self.query_builder.get_simple_query( - extra_select=[self.relationship_column], - order_by=self.order_by - ) - .filter(self.relationship_column.in_(set(ids))) - .filter(*self.filters) + def get_query(self, ids: list[int]): + query = ( + self.query_builder.get_simple_query( + extra_select=[self.relationship_column] + self.extra_select, + order_by=self.order_by ) + .filter(self.relationship_column.in_(set(ids))) + .filter(*self.filters) + ) - for joined_table in self.extra_join: - query = query.join(joined_table) + for joined_table in self.extra_join: + query = query.join(joined_table) - if self.filters: - query = query.filter(*self.filters) + if self.filters: + query = query.filter(*self.filters) + return query + + async def load(self, ids: List[int]): + query = self.get_query(ids) + + async with async_session() as db: data = (await db.execute(query)).all() - - result_data = defaultdict(list) - for item, rel_id in data: - result_data[rel_id].append(item) - + result_data = self.process_data(data) return [result_data[id_] for id_ in ids] + + def process_data(self, data): + result_data = defaultdict(list) + for row in data: + item, rel_id = row[0:2] + extra = row[2:] + if extra: + log.warning(f"Override function process_data, extra params={extra} are going to be discarded!") + + result_data[rel_id].append(item) + return result_data + + +class FlightCopilotDataloader(MultiModelsDataloader): + def process_data(self, data): + result_data = defaultdict(list) + for row in data: + item, rel_id = row[0:2] + token = row[2] + + item.token = token + result_data[rel_id].append(item) + + return result_data diff --git a/src/graphql_schema/dataloaders/multi_models.py b/src/graphql_schema/dataloaders/multi_models.py index 590782b..f3f7479 100644 --- a/src/graphql_schema/dataloaders/multi_models.py +++ b/src/graphql_schema/dataloaders/multi_models.py @@ -1,6 +1,6 @@ from strawberry.dataloader import DataLoader from database import models -from graphql_schema.dataloaders.base import MultiModelsDataloader +from graphql_schema.dataloaders.base import MultiModelsDataloader, FlightCopilotDataloader aircrafts_from_organization_dataloader = DataLoader( load_fn=MultiModelsDataloader( @@ -12,9 +12,14 @@ aircrafts_from_organization_dataloader = DataLoader( ) flight_copilots_dataloader = DataLoader( - load_fn=MultiModelsDataloader( + load_fn=FlightCopilotDataloader( models.Copilot, relationship_column=models.Flight.id, + extra_select=[models.flight_has_copilot.c.token], + filters=[ + models.flight_has_copilot.c.flight_id == models.Flight.id, + models.flight_has_copilot.c.copilot_id == models.Copilot.id, + ], extra_join=[models.Copilot.flights]).load, cache=False) diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index 5c4a63a..fbabb31 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -1,5 +1,6 @@ from typing import List, Optional import strawberry +from graphql import GraphQLError from strawberry.types import Info from database import models from decorators.error_logging import error_logging @@ -27,12 +28,24 @@ class CopilotQueries: id: Optional[int] = None, url_slug: Optional[str] = None, pilot_username: Optional[str] = None, + upload_token: Optional[str] = None, + upload_flight_slug: Optional[str] = None, public: Optional[bool] = False ) -> Copilot: - filter_params = get_detail_filters(id, url_slug) + filter_params = {} + if id: + filter_params['object_id'] = id + if url_slug is not None: + filter_params['url_slug'] = url_slug + if upload_token and upload_flight_slug: + filter_params['upload_token'] = upload_token + filter_params['upload_flight_slug'] = upload_flight_slug if pilot_username: filter_params['pilot_username'] = pilot_username + if not filter_params: + raise GraphQLError(f"Invalid identification supplied: {filter_params}") + return await CopilotQueryResolver().get_one( user_id=info.context.user_id, only_public=public, diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index 5ee6cc2..43d3723 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -1,5 +1,7 @@ from typing import Optional import strawberry +from graphql import GraphQLError + from decorators.endpoints import authenticated_user_only, allow_public from decorators.error_logging import error_logging from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index 6b1e080..86ddd2f 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -1,7 +1,7 @@ from typing import List, Optional import strawberry from database import models -from decorators.endpoints import authenticated_user_only, allow_public +from decorators.endpoints import authenticated_user_only, allow_public, raise_unauthorized from decorators.error_logging import error_logging from graphql_schema.entities.resolvers.base import BaseQueryResolver from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver @@ -18,6 +18,7 @@ class PhotoQueries: root, info, flight_id: Optional[int] = None, copilot_id: Optional[int] = None, + uploaded_by_copilot_id: Optional[int] = None, point_of_interest_id: Optional[int] = None, aircraft_id: Optional[int] = None, public: Optional[bool] = False, @@ -27,6 +28,7 @@ class PhotoQueries: flight_id=flight_id, user_id=info.context.user_id, copilot_id=copilot_id, + uploaded_by_copilot_id=uploaded_by_copilot_id, aircraft_id=aircraft_id, point_of_interest_id=point_of_interest_id, order_by=[models.Photo.exposed_at] @@ -47,8 +49,10 @@ class PhotoQueries: class PhotoMutation: @strawberry.mutation @error_logging - @authenticated_user_only() async def upload_photo(self, info, input: UploadPhotoInput) -> Photo: + if info.context.user_id is None and not input.copilot_upload_token: + raise_unauthorized() + return await PhotoMutationResolver().upload(info, input) @strawberry.mutation() @@ -59,11 +63,14 @@ class PhotoMutation: @strawberry.mutation() @error_logging - @authenticated_user_only() - async def change_orientation(self, info, id: int, direction: str) -> Photo: + async def change_orientation(self, info, id: int, direction: str, copilot_upload_token: Optional[str] = None) -> Photo: + if info.context.user_id is None and not copilot_upload_token: + raise_unauthorized() + return await PhotoMutationResolver().change_orientation( id=id, user_id=info.context.user_id, + copilot_upload_token=copilot_upload_token, direction=direction, info=info ) @@ -76,6 +83,8 @@ class PhotoMutation: @strawberry.mutation() @error_logging - @authenticated_user_only() - async def delete_photo(self, info, id: int) -> Photo: + async def delete_photo(self, info, id: int, copilot_upload_token: Optional[str] = None) -> Photo: + if info.context.user_id is None and not copilot_upload_token: + raise_unauthorized() + return await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id) diff --git a/src/graphql_schema/entities/resolvers/base.py b/src/graphql_schema/entities/resolvers/base.py index d416bd6..bbcfb08 100644 --- a/src/graphql_schema/entities/resolvers/base.py +++ b/src/graphql_schema/entities/resolvers/base.py @@ -120,7 +120,7 @@ class BaseMutationResolver(BaseResolver): item = await self._get_one(db, id, user_id) return await self._do_update(db, item, data.to_dict()) - async def delete(self, user_id: int, id: int) -> GQL_TYPE: + async def delete(self, user_id: int, id: int, **kwargs) -> GQL_TYPE: async with get_session() as db: model = await self._get_one(db, id, user_id) diff --git a/src/graphql_schema/entities/resolvers/copilot.py b/src/graphql_schema/entities/resolvers/copilot.py index f26bd06..3183542 100644 --- a/src/graphql_schema/entities/resolvers/copilot.py +++ b/src/graphql_schema/entities/resolvers/copilot.py @@ -1,4 +1,7 @@ from typing import Optional + +from sqlalchemy import and_ + from database import models from graphql_schema.entities.resolvers.base import BaseQueryResolver from graphql_schema.entities.types.types import Copilot @@ -17,6 +20,8 @@ class CopilotQueryResolver(BaseQueryResolver): **kwargs ): pilot_username = kwargs.pop("pilot_username", None) + upload_token = kwargs.pop("upload_token", None) + upload_flight_slug = kwargs.pop("upload_flight_slug", None) query = super().get_query( user_id=user_id, object_id=object_id, order_by=order_by, @@ -31,4 +36,14 @@ class CopilotQueryResolver(BaseQueryResolver): .filter(models.User.public_username == pilot_username) ) + if upload_token and upload_flight_slug: + query = ( + query.join(models.Copilot.flights) + .filter(and_( + models.Flight.url_slug == upload_flight_slug, + models.Flight.created_by_id == models.User.id + )) + .filter(models.flight_has_copilot.c.token == upload_token) + ) + return query diff --git a/src/graphql_schema/entities/resolvers/flight.py b/src/graphql_schema/entities/resolvers/flight.py index e4ba81b..66c350a 100644 --- a/src/graphql_schema/entities/resolvers/flight.py +++ b/src/graphql_schema/entities/resolvers/flight.py @@ -17,6 +17,7 @@ from graphql_schema.entities.types.mutation_input import ( from graphql_schema.entities.types.types import Flight from paths import FLIGHT_GPX_TRACK_PATH from utils.file import delete_file +from utils.str_utils import random_str from utils.upload import handle_file_upload @@ -232,7 +233,11 @@ class FlightMutationResolver(BaseMutationResolver): ]) for copilot_id in copilots: - await db.execute(insert(flight_has_copilot).values(flight_id=flight_id, copilot_id=copilot_id)) + await db.execute(insert(flight_has_copilot).values( + flight_id=flight_id, + copilot_id=copilot_id, + token=random_str(64) + )) return await self._do_update(db, flight_data, data) diff --git a/src/graphql_schema/entities/resolvers/photo.py b/src/graphql_schema/entities/resolvers/photo.py index 5f6c38a..536c122 100644 --- a/src/graphql_schema/entities/resolvers/photo.py +++ b/src/graphql_schema/entities/resolvers/photo.py @@ -4,7 +4,7 @@ from time import time from typing import Optional from PIL import Image from pydantic import BaseModel -from sqlalchemy import delete, insert +from sqlalchemy import delete, insert, select from background_jobs.elevation import add_terrain_elevation_to_photo from background_jobs.photo import generate_thumbnail, resize_photo from database import models @@ -54,6 +54,9 @@ class PhotoQueryResolver(BaseQueryResolver): .filter(models.copilot_has_photo.c.copilot_id == kwargs['copilot_id']) ) + if kwargs.get("uploaded_by_copilot_id"): + query = query.filter(models.Photo.created_by_copilot_id == kwargs['uploaded_by_copilot_id']) + return query @@ -76,9 +79,13 @@ class PhotoMutationResolver(BaseMutationResolver): return original_filename - async def _get_photo_details(self, id: int, user_id: int): + async def _get_photo_details(self, id: int, user_id: int, copilot_token: Optional[str]): async with get_session() as db: - photo = await self._get_one(db, id, created_by_id=user_id) + if not copilot_token: + photo = await self._get_one(db, id, created_by_id=user_id) + else: + photo = await self._get_photo_info_by_copilot_token(db, photo_id=id, copilot_token=copilot_token) + flight_id = photo.flight_id filename = photo.filename + "." + photo.filename_extension @@ -87,17 +94,48 @@ class PhotoMutationResolver(BaseMutationResolver): flight_id=flight_id, path=path, filename=filename, - original_filename=self._copy_original(path, filename) + original_filename=self._copy_original(path, filename) # TODO: tohle nedelat v getteru! ) + @staticmethod + async def _get_photo_info_by_copilot_token(db, photo_id: int, copilot_token: Optional[str]): + if not copilot_token: + return + + return ( + await db.scalars( + select(models.Photo) + .join(models.Photo.flight) + .join(models.flight_has_copilot) + .filter(models.flight_has_copilot.c.token == copilot_token) + .filter(models.Photo.id == photo_id) + + ) + ).one() + + @staticmethod + async def _validate_copilot_token(flight_id: int, token: str) -> tuple[int, int]: + async with get_session() as db: + query = ( + select(models.Copilot.created_by_id, models.Copilot.id) + .join(models.flight_has_copilot) + .filter(models.flight_has_copilot.c.flight_id == flight_id) + .filter(models.flight_has_copilot.c.token == token) + ) + return (await db.execute(query)).fetchone() + async def upload(self, info, input: UploadPhotoInput) -> Photo: + created_by_id = info.context.user_id + copilot_id = None + if input.copilot_upload_token: + created_by_id, copilot_id = await self._validate_copilot_token(input.flight_id, input.copilot_upload_token) + path = get_photo_basepath(input.flight_id) img_name = await handle_file_upload(input.photo, path, uid_prefix=False, overwrite=False) - exif_info = await parse_exif_info(path, img_name) - img = Image.open(f"{path}/{img_name}") filename, filename_ext = os.path.splitext(img_name) + exif_info = await parse_exif_info(path, img_name) async with get_session() as db: photo = await PhotoMutationResolver()._do_create( @@ -115,7 +153,8 @@ class PhotoMutationResolver(BaseMutationResolver): "gps_latitude": exif_info.get("gps_latitude"), "gps_longitude": exif_info.get("gps_longitude"), "gps_altitude": exif_info.get("gps_altitude"), - "created_by_id": info.context.user_id, + "created_by_id": created_by_id, + "created_by_copilot_id": copilot_id }, ) @@ -148,8 +187,10 @@ class PhotoMutationResolver(BaseMutationResolver): 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) + async def change_orientation( + self, id: int, user_id: int, direction: str, copilot_upload_token: Optional[str], info + ): + photo = await self._get_photo_details(id, user_id, copilot_upload_token) degrees_map = { "clockwise": 90, @@ -176,7 +217,7 @@ class PhotoMutationResolver(BaseMutationResolver): }) async def adjust(self, id: int, user_id: int, adjustment: AdjustmentInput, info): - photo = await self._get_photo_details(id, user_id) + photo = await self._get_photo_details(id, user_id, copilot_token=None) editor = ( PhotoEditor(photo.path, photo.original_filename) .adjust( @@ -221,9 +262,12 @@ class PhotoMutationResolver(BaseMutationResolver): "cache_key": int(time()) }) - async def delete(self, user_id: int, id: int) -> Photo: - photo = await super().delete(user_id, id) + async def delete(self, user_id: int, id: int, **kwargs) -> Photo: + copilot_upload_token = kwargs.get("copilot_upload_token") + await self._get_photo_details(id, user_id, copilot_upload_token) + # ^^ pokud neexistuje, vyhodi vyjimku + photo = await super().delete(user_id, id) base_path = get_photo_basepath(photo.flight_id) files_to_delete = [ diff --git a/src/graphql_schema/entities/types/mutation_input.py b/src/graphql_schema/entities/types/mutation_input.py index adb757e..628d376 100644 --- a/src/graphql_schema/entities/types/mutation_input.py +++ b/src/graphql_schema/entities/types/mutation_input.py @@ -61,6 +61,7 @@ class UploadPhotoInput: name: Optional[str] = None description: Optional[str] = None point_of_interest: Optional[ComboboxInput] = None + copilot_upload_token: Optional[str] = None @strawberry.input diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py index 8e1ed7c..8b6ccbd 100644 --- a/src/graphql_schema/entities/types/types.py +++ b/src/graphql_schema/entities/types/types.py @@ -170,9 +170,16 @@ class Copilot: return await dataloader.load(root.id) + def resolve_copilot_token(root, info): + return getattr(root, "token") + flights: List[Flight] = strawberry.field(resolver=resolve_flights) photos: List[Photo] = strawberry.field(resolver=lambda root: photo_copilots_dataloader.load(root.id)) title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id)) + token: Optional[str] = strawberry.field( + permission_classes=[IsAuthenticated], + resolver=resolve_copilot_token + ) @strawberry_sqlalchemy_type(models.Aircraft) diff --git a/src/utils/str_utils.py b/src/utils/str_utils.py new file mode 100644 index 0000000..1ccee89 --- /dev/null +++ b/src/utils/str_utils.py @@ -0,0 +1,7 @@ +import random +import string + + +def random_str(length: int) -> str: + alphabet = string.ascii_letters + string.digits + return ''.join([random.choice(alphabet) for _ in range(length)])