From 7f7f79dad69fdf99c3831124a382fb982aa0d7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Fri, 13 Oct 2023 23:24:53 +0200 Subject: [PATCH] Refaktoring a bugfixing --- src/background_jobs/elevation.py | 9 +- src/background_jobs/photo.py | 15 ++ src/background_jobs/weather.py | 41 ++++ src/database/models.py | 14 +- .../db.py => database/transaction.py} | 0 src/dependencies/__init__.py | 0 src/dependencies/jwt.py | 10 - src/endpoints/login.py | 2 +- src/endpoints/registration.py | 2 +- src/external/gpx_parser.py | 8 +- src/graphql_schema/entities/aircraft.py | 94 +++------ src/graphql_schema/entities/airport.py | 15 +- src/graphql_schema/entities/copilot.py | 42 ++-- src/graphql_schema/entities/event.py | 46 +---- src/graphql_schema/entities/flight.py | 100 ++++------ src/graphql_schema/entities/organization.py | 77 +++----- src/graphql_schema/entities/photo.py | 79 ++++---- src/graphql_schema/entities/poi.py | 51 ++--- src/graphql_schema/entities/poi_type.py | 10 +- .../entities/resolvers/aircraft.py | 49 +++-- src/graphql_schema/entities/resolvers/base.py | 147 +++++++++----- .../entities/resolvers/flight.py | 183 ++++++------------ src/graphql_schema/entities/types/base.py | 21 ++ src/graphql_schema/entities/types/flight.py | 0 .../entities/types/mutation_input.py | 68 ++++++- src/graphql_schema/entities/types/types.py | 129 +++++------- src/graphql_schema/entities/user.py | 11 +- src/graphql_schema/mutation.py | 23 +-- .../sqlalchemy_to_strawberry_type.py | 18 +- src/logger.py | 22 +++ src/paths.py | 34 ++++ src/scripts/elevation.py | 6 +- src/utils/file.py | 14 ++ src/utils/gps.py | 6 + src/{upload_utils.py => utils/image.py} | 57 +----- src/utils/upload.py | 17 ++ 36 files changed, 698 insertions(+), 722 deletions(-) create mode 100644 src/background_jobs/photo.py create mode 100644 src/background_jobs/weather.py rename src/{dependencies/db.py => database/transaction.py} (100%) delete mode 100644 src/dependencies/__init__.py delete mode 100644 src/dependencies/jwt.py delete mode 100644 src/graphql_schema/entities/types/flight.py create mode 100644 src/logger.py create mode 100644 src/utils/file.py create mode 100644 src/utils/gps.py rename src/{upload_utils.py => utils/image.py} (62%) create mode 100644 src/utils/upload.py diff --git a/src/background_jobs/elevation.py b/src/background_jobs/elevation.py index fec60c2..520525a 100644 --- a/src/background_jobs/elevation.py +++ b/src/background_jobs/elevation.py @@ -1,21 +1,20 @@ from aiohttp import ClientResponseError from database import models -from dependencies.db import get_session +from database.transaction import get_session from external.elevation import elevation_api from external.gpx_parser import GPXParser +from paths import FLIGHT_GPX_TRACK_PATH async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str): - path = "/app/uploads/tracks" # TODO vytahnout do configu - - gpx_parser = GPXParser(f"{path}/{gpx_filename}") + gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}") coordinates = await gpx_parser.get_coordinates() try: elevation = await elevation_api.get_elevation_for_points(coordinates) tree_with_elevation = gpx_parser.add_terrain_elevation(elevation) output_name = f"terrain_{gpx_filename}" - gpx_parser.write(tree_with_elevation, f"{path}/{output_name}") + gpx_parser.write(tree_with_elevation, f"{FLIGHT_GPX_TRACK_PATH}/{output_name}") async with get_session() as db: await models.Flight.update( diff --git a/src/background_jobs/photo.py b/src/background_jobs/photo.py new file mode 100644 index 0000000..97da3c3 --- /dev/null +++ b/src/background_jobs/photo.py @@ -0,0 +1,15 @@ +from utils.image import resize_image + + +async def resize_photo(path: str, filename: str): + return await resize_image(path, filename, new_width=2500, quality=85) + + +async def generate_thumbnail(path: str, filename: str, quality: int = 90): + return await resize_image( + path, filename, + new_width=300, + dest_path=f"{path}/thumbs/", + dest_filename=filename, + quality=quality + ) diff --git a/src/background_jobs/weather.py b/src/background_jobs/weather.py new file mode 100644 index 0000000..38b4369 --- /dev/null +++ b/src/background_jobs/weather.py @@ -0,0 +1,41 @@ +from datetime import datetime +from typing import Literal +from database import models +from database.transaction import get_session +from external.weather import weather_api +from logger import log + + +async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']): + log.error("AAAAAAAAAAAAAAAAAAAAAAAAAAA") + async with get_session() as db: + airport = await models.Airport.get_one(db, airport_id) + gps = (airport.gps_latitude, airport.gps_longitude) + + try: + weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps) + except Exception as e: + log.error(f"Error in downloading weather: {e}") + return None + + data = { + "datetime": weather['datetime'], + "qnh": weather['pressure_msl'], + "temperature_surface": weather['temperature_2m'], + "dewpoint_surface": weather['dewpoint_2m'], + "rain": weather['rain'], + "cloudcover_total": weather['cloudcover'], + "cloudcover_low": weather['cloudcover_low'], + "wind_speed_surface": weather['windspeed_10m'], + "wind_direction_surface": weather['winddirection_10m'], + } + + async with get_session() as db: + flight = await models.Flight.get_one(db, flight_id) + existing_weather_id = getattr(flight, f'{type_}_weather_info_id') + + if existing_weather_id: + await models.WeatherInfo.update(db, id=existing_weather_id, data=data) + else: + weather_model = await models.WeatherInfo.create(db, data=data) + await models.Flight.update(db, obj=flight, data={f"{type_}_weather_info_id": weather_model.id}) diff --git a/src/database/models.py b/src/database/models.py index 9d7cdff..093aacf 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -10,12 +10,12 @@ from sqlalchemy.ext.asyncio import AsyncSession class BaseModel: excluded_columns_in_dict = ("deleted",) + @classmethod + def _get_column_names(cls): + return [col.name for col in cls.__table__.columns] + def as_dict(self): - return { - c.name: getattr(self, c.name) - for c in self.__table__.columns - if c.name not in self.excluded_columns_in_dict - } + return {c: getattr(self, c) for c in self._get_column_names() if c not in self.excluded_columns_in_dict} @classmethod async def get_one(cls, db_session: AsyncSession, id: int): @@ -23,7 +23,7 @@ class BaseModel: @classmethod async def create(cls, db_session: AsyncSession, data: dict): - model = cls(**data) + model = cls(**{col: data[col] for col in cls._get_column_names() if col in data}) db_session.add(model) await db_session.flush() @@ -37,7 +37,7 @@ class BaseModel: if not obj: obj = await cls.get_one(db_session, id) for key, value in data.items(): - if getattr(obj, key) != value: + if key in cls._get_column_names() and getattr(obj, key) != value: setattr(obj, key, value) return obj diff --git a/src/dependencies/db.py b/src/database/transaction.py similarity index 100% rename from src/dependencies/db.py rename to src/database/transaction.py diff --git a/src/dependencies/__init__.py b/src/dependencies/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/dependencies/jwt.py b/src/dependencies/jwt.py deleted file mode 100644 index 7bf4ea7..0000000 --- a/src/dependencies/jwt.py +++ /dev/null @@ -1,10 +0,0 @@ -# from fastapi import Depends, Security -# from fastapi_jwt import JwtAuthorizationCredentials -# -# -# async def check_jwt_token(): -# authorize.jwt_required() -# -# subject = authorize.get_jwt_subject() -# -# print("Subject: ", subject) diff --git a/src/endpoints/login.py b/src/endpoints/login.py index 44b5c51..ad68379 100644 --- a/src/endpoints/login.py +++ b/src/endpoints/login.py @@ -4,7 +4,7 @@ from passlib.hash import bcrypt from sqlalchemy import select from starlette.responses import Response from database.models import User -from dependencies.db import get_session +from database.transaction import get_session from endpoints.base import AuthEndpoint from pydantic import BaseModel diff --git a/src/endpoints/registration.py b/src/endpoints/registration.py index 53fda73..b43358e 100644 --- a/src/endpoints/registration.py +++ b/src/endpoints/registration.py @@ -4,7 +4,7 @@ from sqlalchemy import select from typing import Optional from pydantic import BaseModel, root_validator, Field from database.models import User -from dependencies.db import get_session +from database.transaction import get_session from passlib.hash import bcrypt diff --git a/src/external/gpx_parser.py b/src/external/gpx_parser.py index 55cbbba..b351037 100644 --- a/src/external/gpx_parser.py +++ b/src/external/gpx_parser.py @@ -53,16 +53,19 @@ class GPXParser: @cached() async def get_max_speed(self): - return max(await self.get_speed()) + return max(await self.get_speed()) or 0 @cached() async def get_avg_speed(self): speeds = await self.get_speed() + if not speeds: + return 0 + return round(sum(speeds) / len(speeds), 2) @cached() async def get_max_altitude(self): - return max(await self.get_altitude()) + return max(await self.get_altitude()) or 0 @cached() async def get_avg_altitude(self): @@ -89,5 +92,4 @@ class GPXParser: return self.gpx def write(self, tree: _ElementTree, output: str): - print(f"ZAPISUJI DO {output}") tree.write(output) diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 96ab080..f3684ab 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -1,56 +1,46 @@ -from typing import List, Optional, TYPE_CHECKING +from typing import List import strawberry -from strawberry.file_uploads import Upload from database import models from decorators.endpoints import authenticated_user_only -from dependencies.db import get_session -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input -from upload_utils import handle_file_upload, delete_file +from database.transaction import get_session from graphql_schema.entities.helpers.combobox import handle_combobox_save -from .resolvers.aircraft import get_aircraft_resolver -from .resolvers.base import get_list, get_one -from graphql_schema.entities.types.mutation_input import ComboboxInput +from paths import AIRCRAFT_UPLOAD_DEST_PATH +from utils.file import delete_file +from utils.upload import handle_file_upload +from .resolvers.aircraft import AircraftMutationResolver, AircraftQueryResolver +from graphql_schema.entities.types.mutation_input import CreateAircraftInput, EditAircraftInput from graphql_schema.entities.types.types import Aircraft -if TYPE_CHECKING: - pass - -AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/" - @strawberry.type class AircraftQueries: @strawberry.field() @authenticated_user_only() async def aircrafts(root, info) -> List[Aircraft]: - query = get_aircraft_resolver(info.context.user_id, info.context.organization_ids) - return await get_list(models.Aircraft, query) + return await AircraftQueryResolver().get_list( + info.context.user_id, + organization_ids=info.context.organization_ids + ) @strawberry.field() @authenticated_user_only() async def aircraft(root, info, id: int) -> Aircraft: - query = get_aircraft_resolver(info.context.user_id, info.context.organization_ids, id) - return await get_one(models.Aircraft, query) + return await AircraftQueryResolver().get_one( + id, user_id=info.context.user_id, organization_ids=info.context.organization_ids + ) @strawberry.type -class CreateAircraftMutation: - @strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename']) - class CreateAircraftInput: - photo: Optional[Upload] - organization: Optional[ComboboxInput] = None - +class AircraftMutation: @strawberry.mutation @authenticated_user_only() async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: - # TODO: kontrola organizace - input_data = input.to_dict() if input.photo: input_data['photo_filename'] = await handle_file_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH) - async with get_session() as db: - if input.organization: + if input.organization: + async with get_session() as db: input_data['organization_id'] = await handle_combobox_save( db, models.Organization, @@ -58,29 +48,11 @@ class CreateAircraftMutation: user_id=info.context.user_id, ) - aircraft = await models.Aircraft.create( - db, - data=dict( - **input_data, - created_by_id=info.context.user_id, - ) - ) - - return Aircraft(**aircraft.as_dict()) - - -@strawberry.type -class EditAircraftMutation: - @strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['photo_filename']) - class EditAircraftInput: - photo: Optional[Upload] - organization: Optional[ComboboxInput] = None + return await AircraftMutationResolver().create(data=input_data, user_id=info.context.user_id) @strawberry.mutation @authenticated_user_only() async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft: - # TODO: kontrola organizace - update_data = input.to_dict() async with get_session() as db: if input.organization: @@ -91,26 +63,24 @@ class EditAircraftMutation: user_id=info.context.user_id, ) - aircraft = (await db.scalars(get_aircraft_resolver(user_id=info.context.user_id, aircraft_id=id))).one() + query = AircraftQueryResolver().get_query( + user_id=info.context.user_id, + object_id=id, + organization_ids=info.context.organization_ids + ) + aircraft = (await db.scalars(query)).one() + existing_photo_filename = aircraft.photo_filename - if input.photo: - if aircraft.photo_filename: - delete_file(AIRCRAFT_UPLOAD_DEST_PATH + "/" + aircraft.photo_filename, silent=True) - update_data['photo_filename'] = await handle_file_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH) - - aircraft = await models.Aircraft.update(db, obj=aircraft, data=update_data) + if input.photo: + if existing_photo_filename: + delete_file(AIRCRAFT_UPLOAD_DEST_PATH + "/" + existing_photo_filename, silent=True) + update_data['photo_filename'] = await handle_file_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH) + async with get_session() as db: + aircraft = await models.Aircraft.update(db, id=id, data=update_data) return Aircraft(**aircraft.as_dict()) - -@strawberry.type -class DeleteAircraftMutation: - @strawberry.mutation @authenticated_user_only() async def delete_aircraft(self, info, id: int) -> Aircraft: - async with get_session() as db: - aircraft = (await db.scalars(get_aircraft_resolver(info.context.user_id, aircraft_id=id))).one() - - aircraft = await models.Aircraft.update(db, obj=aircraft, data=dict(deleted=True)) - return Aircraft(**aircraft.as_dict()) + return await AircraftMutationResolver().delete(info.context.user_id, id) diff --git a/src/graphql_schema/entities/airport.py b/src/graphql_schema/entities/airport.py index 7ae94a1..f197037 100644 --- a/src/graphql_schema/entities/airport.py +++ b/src/graphql_schema/entities/airport.py @@ -2,13 +2,8 @@ from typing import List import strawberry from database import models from decorators.endpoints import authenticated_user_only -from graphql_schema.entities.resolvers.base import get_list, get_one, get_base_resolver -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type - - -@strawberry_sqlalchemy_type(models.Airport) -class Airport: - pass +from graphql_schema.entities.resolvers.base import BaseQueryResolver +from graphql_schema.entities.types.types import Airport @strawberry.type @@ -16,11 +11,9 @@ class AirportQueries: @strawberry.field() @authenticated_user_only() async def airports(root, info) -> List[Airport]: - query = get_base_resolver(models.Airport, user_id=info.context.user_id) - return await get_list(models.Airport, query) + return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id) @strawberry.field() @authenticated_user_only() async def airport(root, info, id: int) -> Airport: - query = get_base_resolver(models.Airport, user_id=info.context.user_id, object_id=id) - return await get_one(models.Airport, query) + return await BaseQueryResolver(Airport, models.Airport).get_one(id, user_id=info.context.user_id) diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index 2493c43..d12e523 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -1,65 +1,45 @@ -from typing import List, TYPE_CHECKING +from typing import List import strawberry from database import models from decorators.endpoints import authenticated_user_only -from dependencies.db import get_session -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input -from .resolvers.base import get_base_resolver, get_list, get_one +from database.transaction import get_session +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver +from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput from graphql_schema.entities.types.types import Copilot -if TYPE_CHECKING: - pass - @strawberry.type class CopilotQueries: @strawberry.field() @authenticated_user_only() async def copilots(root, info) -> List[Copilot]: - query = get_base_resolver(models.Copilot, user_id=info.context.user_id, order_by=[models.Copilot.name]) - return await get_list(models.Copilot, query) + 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: - query = get_base_resolver(models.Copilot, object_id=id, user_id=info.context.user_id) - return await get_one(models.Copilot, query) + return await BaseQueryResolver(Copilot, models.Copilot).get_one(id, user_id=info.context.user_id) @strawberry.type class CreateCopilotMutation: - @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) - class CreateCopilotInput: - pass - @strawberry.mutation @authenticated_user_only() async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot: - input_data = input.to_dict() - async with get_session() as db: - copilot = await models.Copilot.create( - db, - data=dict( - **input_data, - created_by_id=info.context.user_id, - ) - ) - - return Copilot(**copilot.as_dict()) + return await BaseMutationResolver(Copilot, models.Copilot).create( + data=input.to_dict(), + user_id=info.context.user_id + ) @strawberry.type class EditCopilotMutation: - @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) - class EditCopilotInput: - pass - @strawberry.mutation @authenticated_user_only() async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot: async with get_session() as db: copilot = (await db.scalars( - query=get_base_resolver(models.Copilot, object_id=id, user_id=info.context.user_id) + BaseQueryResolver(Copilot, models.Copilot).get_query(object_id=id, user_id=info.context.user_id) )).one() updated_copilot = await models.Copilot.update(db, obj=copilot, data=input.to_dict()) diff --git a/src/graphql_schema/entities/event.py b/src/graphql_schema/entities/event.py index b2db6b5..f643df6 100644 --- a/src/graphql_schema/entities/event.py +++ b/src/graphql_schema/entities/event.py @@ -1,68 +1,42 @@ -from typing import List, TYPE_CHECKING +from typing import List import strawberry from database import models from decorators.endpoints import authenticated_user_only -from dependencies.db import get_session -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input -from .resolvers.base import get_base_resolver, get_list, get_one +from database.transaction import get_session +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver +from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput from graphql_schema.entities.types.types import Event -if TYPE_CHECKING: - pass - @strawberry.type class EventQueries: @strawberry.field() @authenticated_user_only() async def events(root, info) -> List[Event]: - query = get_base_resolver( - models.Event, user_id=info.context.user_id, + return await BaseQueryResolver(Event, models.Event).get_list( + info.context.user_id, order_by=[models.Event.date_from.desc(), models.Event.id.desc()] ) - return await get_list(models.Event, query) @strawberry.field() @authenticated_user_only() async def event(root, info, id: int) -> Event: - query = get_base_resolver(models.Event, user_id=info.context.user_id, object_id=id) - return await get_one(models.Event, query) + return await BaseQueryResolver(Event, models.Event).get_one(id, user_id=info.context.user_id) @strawberry.type -class CreateEventMutation: - @strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"]) - class CreateEventInput: - pass - +class EventMutation: @strawberry.mutation @authenticated_user_only() async def create_event(root, info, input: CreateEventInput) -> Event: - input_data = input.to_dict() - async with get_session() as db: - event = await models.Event.create( - db, - data=dict( - **input_data, - created_by_id=info.context.user_id, - ) - ) - - return Event(**event.as_dict()) - - -@strawberry.type -class EditEventMutation: - @strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"]) - class EditEventInput: - pass + return await BaseMutationResolver(Event, models.Event).create(input.to_dict(), info.context.user_id) @strawberry.mutation @authenticated_user_only() async def edit_event(root, info, id: int, input: EditEventInput) -> Event: async with get_session() as db: event = (await db.scalars( - get_base_resolver(models.Event, user_id=info.context.user_id, object_id=id) + BaseQueryResolver(Event, models.Event).get_query(user_id=info.context.user_id, object_id=id) )).one() updated_event = await models.Event.update(db, obj=event, data=input.to_dict()) diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index b9e6551..04cbbff 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -2,43 +2,18 @@ import asyncio from typing import List, Optional import strawberry from fastapi import HTTPException -from sqlalchemy import select from starlette.status import HTTP_401_UNAUTHORIZED +from background_jobs.weather import download_weather from database import models from decorators.endpoints import authenticated_user_only from decorators.error_logging import error_logging -from dependencies.db import get_session -from graphql_schema.entities.resolvers.flight import ( - handle_aircraft_save, handle_weather_info, FlightMutationResolver, get_airport, -) +from database.transaction import get_session +from graphql_schema.entities.resolvers.flight import handle_aircraft_save, FlightMutationResolver, FlightQueryResolver from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput -from .resolvers.base import get_list, get_one +from .helpers.combobox import handle_combobox_save from graphql_schema.entities.types.types import Flight -def get_base_query(user_id: Optional[int], username: Optional[str] = None, is_auth: bool = False): - query = ( - select(models.Flight) - .filter(models.Flight.deleted.is_(False)) - .order_by(models.Flight.takeoff_datetime.desc()) - ) - - if user_id: - query = query.filter(models.Flight.created_by_id == user_id) - - if username: - query = ( - query - .join(models.Flight.created_by) - .filter(models.User.public_username == username) - ) - - if not is_auth: - query = query.filter(models.Flight.is_public.is_(True)) - - return query - - @strawberry.type class FlightQueries: @@ -47,11 +22,7 @@ class FlightQueries: if not info.context.user_id and not username: raise HTTPException(HTTP_401_UNAUTHORIZED) - query = ( - get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) - .order_by(models.Flight.id.desc()) - ) - return await get_list(models.Flight, query) + return await FlightQueryResolver().get_list(user_id=info.context.user_id, username=username) @strawberry.field() @error_logging @@ -59,11 +30,7 @@ class FlightQueries: if not info.context.user_id and not username: raise HTTPException(HTTP_401_UNAUTHORIZED) - query = ( - get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) - .filter(models.Flight.id == id) - ) - return await get_one(models.Flight, query) + return await FlightQueryResolver().get_one(id, user_id=info.context.user_id, username=username) @strawberry.type @@ -72,36 +39,49 @@ class FlightMutation: @authenticated_user_only() async def create_flight(self, info, input: CreateFlightInput) -> Flight: data = input.to_dict() - - async with (get_session() as db): - takeoff_airport = await get_airport(db, input.takeoff_airport, info.context.user_id) - landing_airport = await get_airport(db, input.landing_airport, info.context.user_id) - + user_id = info.context.user_id + async with get_session() as db: aircraft_id = await handle_aircraft_save(db, info.context.user_id, input.aircraft) - weather_takeoff, weather_landing = await asyncio.gather( - handle_weather_info(db, data['takeoff_datetime'], takeoff_airport), - handle_weather_info(db, data['landing_datetime'], landing_airport) - ) - await db.flush() - flight = await models.Flight.create(db, data={ - **data, - "takeoff_weather_info_id": weather_takeoff.id if weather_takeoff else None, - "landing_weather_info_id": weather_landing.id if weather_landing else None, - "takeoff_airport_id": takeoff_airport.id, - "landing_airport_id": landing_airport.id, - "has_terrain_elevation": False, + # TODO: tohle je blbost, bude to vyrabet dve stejne instance! + takeoff_airport_id, landing_airport_id = await asyncio.gather( + handle_combobox_save( + db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code", + extra_data={"name": input.takeoff_airport.name} + ), + handle_combobox_save( + db, models.Airport, input.landing_airport, user_id, name_column="icao_code", + extra_data={"name": input.landing_airport.name} + ) + ) + + data.update({ + "takeoff_airport_id": takeoff_airport_id, + "landing_airport_id": landing_airport_id, "aircraft_id": aircraft_id, - "created_by_id": info.context.user_id + "has_terrain_elevation": False, + "name": "", + "description": "" }) - return Flight(**flight.as_dict()) + flight = FlightMutationResolver().create(data, info.context.user_id) + + info.context.background_tasks.add_task( + download_weather, + flight_id=flight.id, airport_id=takeoff_airport_id, date_time=flight.takeoff_datetime, type_="takeoff" + ) + info.context.background_tasks.add_task( + download_weather, + flight_id=flight.id, airport_id=landing_airport_id, date_time=flight.landing_datetime, type_="landing" + ) + + return flight @strawberry.mutation @authenticated_user_only() async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight: - return await FlightMutationResolver.update(info.context, id, input) + return await FlightMutationResolver().update(info.context, id, input) @strawberry.mutation @authenticated_user_only() async def delete_flight(self, info, id: int) -> Flight: - return await FlightMutationResolver.delete(info.context.user_id, id) + return await FlightMutationResolver().delete(info.context.user_id, id) diff --git a/src/graphql_schema/entities/organization.py b/src/graphql_schema/entities/organization.py index 9e6626c..9c98f35 100644 --- a/src/graphql_schema/entities/organization.py +++ b/src/graphql_schema/entities/organization.py @@ -1,54 +1,54 @@ -from typing import List, TYPE_CHECKING +from typing import List import strawberry from sqlalchemy import delete from sqlalchemy.dialects.mysql import insert from sqlalchemy.exc import IntegrityError from database import models from decorators.endpoints import authenticated_user_only -from dependencies.db import get_session -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input -from .resolvers.base import get_base_resolver, get_list, get_one +from database.transaction import get_session +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver +from graphql_schema.entities.types.mutation_input import CreateOrganizationInput, EditOrganizationInput from graphql_schema.entities.types.types import Organization -if TYPE_CHECKING: - pass - @strawberry.type class OrganizationQueries: @strawberry.field() @authenticated_user_only() async def organizations(root, info) -> List[Organization]: - query = get_base_resolver(models.Organization, order_by=[models.Organization.name]) - return await get_list(models.Organization, query) + return await BaseQueryResolver(Organization, models.Organization).get_list( + info.context.user_id, order_by=[models.Organization.name] + ) @strawberry.field() @authenticated_user_only() async def organization(root, info, id: int) -> Organization: - query = get_base_resolver(models.Organization, object_id=id) - return await get_one(models.Organization, query) + return await BaseQueryResolver(Organization, models.Organization).get_one(id, info.context.user_id) @strawberry.type -class CreateOrganizationMutation: - @strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"]) - class CreateOrganizationInput: - pass +class OrganizationMutation: @strawberry.mutation @authenticated_user_only() async def create_organization(root, info, input: CreateOrganizationInput) -> Organization: - input_data = input.to_dict() - async with get_session() as db: - organization = await models.Organization.create( - db, - data=dict( - **input_data, - created_by_id=info.context.user_id, - ) - ) + return await BaseMutationResolver(Organization, models.Organization).create( + data=input.to_dict(), + user_id=info.context.user_id + ) - return Organization(**organization.as_dict()) + @strawberry.mutation + @authenticated_user_only() + async def edit_organization(root, info, id: int, input: EditOrganizationInput) -> Organization: + async with get_session() as db: + organization = (await db.scalars( + BaseQueryResolver(Organization, models.Organization).get_query( + user_id=info.context.user_id, object_id=id + ) + )).one() + + updated_organization = await models.Organization.update(db, obj=organization, data=input.to_dict()) + return Organization(**updated_organization.as_dict()) @strawberry.type @@ -58,7 +58,9 @@ class OrganizationUserMutation: @authenticated_user_only() async def add_to_organization(root, info, organization_id: int) -> Organization: async with get_session() as db: - organization = (await db.scalars(get_base_resolver(models.Organization, object_id=organization_id))).one() + organization = (await db.scalars( + BaseQueryResolver(Organization, models.Organization).get_query(object_id=organization_id) + )).one() try: await db.execute( @@ -68,7 +70,6 @@ class OrganizationUserMutation: ) ) except IntegrityError: - print("jiz existuje") pass return Organization(**organization.as_dict()) @@ -77,7 +78,9 @@ class OrganizationUserMutation: @authenticated_user_only() async def remove_from_organization(root, info, organization_id: int) -> Organization: async with get_session() as db: - organization = (await db.scalars(get_base_resolver(models.Organization, object_id=organization_id))).one() + organization = (await db.scalars( + BaseQueryResolver(Organization, models.Organization).get_query(object_id=organization_id) + )).one() await db.execute( delete(models.user_is_in_organization).filter_by( @@ -87,21 +90,3 @@ class OrganizationUserMutation: ) return Organization(**organization.as_dict()) - - -@strawberry.type -class EditOrganizationMutation: - @strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"]) - class EditOrganizationInput: - pass - - @strawberry.mutation - @authenticated_user_only() - async def edit_organization(root, info, id: int, input: EditOrganizationInput) -> Organization: - async with get_session() as db: - organization = (await db.scalars( - get_base_resolver(models.Organization, user_id=info.context.user_id, object_id=id) - )).one() - - updated_organization = await models.Organization.update(db, obj=organization, data=input.to_dict()) - return Organization(**updated_organization.as_dict()) diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index bb8d212..0a0d2d2 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -4,17 +4,17 @@ import strawberry from sqlalchemy import update from strawberry.file_uploads import Upload 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 dependencies.db import get_session +from database.transaction import get_session +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver from graphql_schema.entities.types.types import Photo from paths import get_photo_basepath -from upload_utils import ( - handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, resize_image, - rotate_image -) from graphql_schema.entities.helpers.combobox import handle_combobox_save -from .resolvers.base import get_base_resolver, get_list +from utils.file import delete_file +from utils.image import parse_exif_info, rotate_image +from utils.upload import handle_file_upload from graphql_schema.entities.types.mutation_input import ComboboxInput @@ -22,8 +22,7 @@ from graphql_schema.entities.types.mutation_input import ComboboxInput class PhotoQueries: @strawberry.field() async def photos(root, info) -> List[Photo]: - query = get_base_resolver(models.Photo, user_id=info.context.user_id) - return await get_list(models.Photo, query) + return await BaseQueryResolver(Photo, models.Photo).get_list(user_id=info.context.user_id) @strawberry.type @@ -58,8 +57,7 @@ class UploadPhotoMutation: }, db_session=db) photo = Photo(**photo_model.as_dict()) - # TODO: udelat primo konkretni bg joby na resize a thumbnaily - info.context.background_tasks.add_task(resize_image, path=path, filename=filename, new_width=2500, quality=85) + info.context.background_tasks.add_task(resize_photo, path=path, filename=filename) info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename) if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): @@ -77,19 +75,20 @@ class EditPhotoMutation: point_of_interest: Optional[ComboboxInput] = None is_flight_cover: Optional[bool] = None + def to_dict(self): + return { + key: getattr(self, key) for key in ('name', 'description', 'is_flight_cover') + if getattr(self, key) is not None + } + @strawberry.mutation() @authenticated_user_only() async def edit_photo(self, info, id: int, input: EditPhotoInput) -> Photo: - - # TODO: base trida pro inputy s definici to_dict/as_dict? - data = { - key: getattr(input, key) for key in ('name', 'description', 'is_flight_cover') - if getattr(input, key) is not None - } - - query = get_base_resolver(models.Photo, user_id=info.context.user_id, object_id=id) + data = input.to_dict() async with get_session() as db: - photo = (await db.scalars(query)).one() + photo = (await db.scalars( + BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) + )).one() if input.point_of_interest: data['point_of_interest_id'] = await handle_combobox_save( @@ -116,25 +115,27 @@ class EditPhotoMutation: @strawberry.mutation() @authenticated_user_only() async def rotate_photo(self, info, id: int, angle: int) -> Photo: - async with get_session() as db: - query = get_base_resolver(models.Photo, user_id=info.context.user_id, object_id=id) - photo = (await db.scalars(query)).one() + photo = (await db.scalars( + BaseQueryResolver(Photo, models.Photo).get_query(user_id=info.context.user_id, object_id=id) + )).one() + photo_filename = photo.filename + photo_as_dict = photo.as_dict() - await asyncio.gather( - rotate_image( - path=get_photo_basepath(photo.flight_id), - filename=photo.filename, - angle=angle, - ), - rotate_image( - path=get_photo_basepath(photo.flight_id) + "/thumbs", - filename=photo.filename, - angle=angle, - ), - ) + await asyncio.gather( + rotate_image( + path=get_photo_basepath(photo.flight_id), + filename=photo_filename, + angle=angle, + ), + rotate_image( + path=get_photo_basepath(photo.flight_id) + "/thumbs", + filename=photo_filename, + angle=angle, + ), + ) - return Photo(**photo.as_dict()) + return Photo(**photo_as_dict) @strawberry.type @@ -142,14 +143,10 @@ class DeletePhotoMutation: @strawberry.mutation() @authenticated_user_only() async def delete_photo(self, info, id: int) -> Photo: - query = get_base_resolver(models.Photo, user_id=info.context.user_id, object_id=id) - async with get_session() as db: - photo_model = (await db.scalars(query)).one() - await db.delete(photo_model) - photo = Photo(**photo_model.as_dict()) + photo = await BaseMutationResolver(Photo, models.Photo).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_model + return photo diff --git a/src/graphql_schema/entities/poi.py b/src/graphql_schema/entities/poi.py index 0209929..a10cc5f 100644 --- a/src/graphql_schema/entities/poi.py +++ b/src/graphql_schema/entities/poi.py @@ -1,13 +1,12 @@ -from typing import List, Optional +from typing import List import strawberry from database import models from decorators.endpoints import authenticated_user_only -from dependencies.db import get_session +from database.transaction import get_session from graphql_schema.entities.helpers.combobox import handle_combobox_save -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input +from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver from graphql_schema.entities.types.types import PointOfInterest -from .resolvers.base import get_base_resolver, get_list, get_one -from graphql_schema.entities.types.mutation_input import ComboboxInput +from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput @strawberry.type @@ -15,22 +14,16 @@ class PointOfInterestQueries: @strawberry.field() @authenticated_user_only() async def points_of_interest(root, info) -> List[PointOfInterest]: - query = get_base_resolver(models.PointOfInterest, user_id=info.context.user_id) - return await get_list(models.PointOfInterest, query) + return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_list(info.context.user_id) @strawberry.field() @authenticated_user_only() async def point_of_interest(root, info, id: int) -> PointOfInterest: - query = get_base_resolver(models.PointOfInterest, user_id=info.context.user_id, object_id=id) - return await get_one(models.PointOfInterest, query) + return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_one(id, info.context.user_id) @strawberry.type -class CreatePointOfInterestMutation: - @strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id']) - class CreatePointOfInterestInput: - type: Optional[ComboboxInput] = None - +class PointOfInterestMutation: @strawberry.mutation @authenticated_user_only() async def create_point_of_interest(root, info, input: CreatePointOfInterestInput) -> PointOfInterest: @@ -42,23 +35,17 @@ class CreatePointOfInterestMutation: db, models.PointOfInterestType, input.type, info.context.user_id ) - poi = await models.PointOfInterest.create(db, data=dict(**input_data, created_by_id=info.context.user_id)) - return PointOfInterest(**poi.as_dict()) - - -@strawberry.type -class EditPointOfInterestMutation: - @strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id']) - class EditPointOfInterestInput: - type: Optional[ComboboxInput] = None + return await BaseMutationResolver(PointOfInterest, models.PointOfInterest).create( + input_data, info.context.user_id + ) @strawberry.mutation @authenticated_user_only() async def edit_point_of_interest(root, info, id: int, input: EditPointOfInterestInput) -> PointOfInterest: input_data = input.to_dict() - query = get_base_resolver( - models.PointOfInterest, user_id=info.context.user_id, object_id=id, include_public=False + query = BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_query( + user_id=info.context.user_id, object_id=id, include_public=False ) async with get_session() as db: @@ -71,19 +58,7 @@ class EditPointOfInterestMutation: updated_poi = await models.PointOfInterest.update(db, obj=poi, data=input_data) return PointOfInterest(**updated_poi.as_dict()) - -@strawberry.type -class DeletePointOfInterestMutation: - @strawberry.mutation @authenticated_user_only() async def delete_point_of_interest(self, info, id: int) -> PointOfInterest: - query = get_base_resolver( - models.PointOfInterest, user_id=info.context.user_id, object_id=id, include_public=False - ) - - async with get_session() as db: - poi = (await db.scalars(query)).one() - - updated_poi = await models.PointOfInterest.update(db, obj=poi, data=dict(deleted=True)) - return PointOfInterest(**updated_poi.as_dict()) + return await BaseMutationResolver(PointOfInterest, models.PointOfInterest).delete(info.context.user_id, id=id) diff --git a/src/graphql_schema/entities/poi_type.py b/src/graphql_schema/entities/poi_type.py index fef4f8e..1954474 100644 --- a/src/graphql_schema/entities/poi_type.py +++ b/src/graphql_schema/entities/poi_type.py @@ -2,7 +2,7 @@ from typing import List import strawberry from database import models from decorators.endpoints import authenticated_user_only -from graphql_schema.entities.resolvers.base import get_base_resolver, get_list, get_one +from graphql_schema.entities.resolvers.base import BaseQueryResolver from graphql_schema.entities.types.types import PointOfInterestType @@ -12,14 +12,14 @@ class PointOfInterestTypeQueries: @strawberry.field() @authenticated_user_only() async def point_of_interest_types(root, info) -> List[PointOfInterestType]: - query = get_base_resolver(models.PointOfInterestType, user_id=info.context.user_id) - return await get_list(models.PointOfInterestType, query) + return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_list(info.context.user_id) @strawberry.field() @authenticated_user_only() async def point_of_interest_type(root, info, id: int) -> PointOfInterestType: - query = get_base_resolver(models.PointOfInterestType, user_id=info.context.user_id, object_id=id) - return await get_one(models.PointOfInterestType, query) + return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_one( + id, info.context.user_id + ) # # @strawberry.type diff --git a/src/graphql_schema/entities/resolvers/aircraft.py b/src/graphql_schema/entities/resolvers/aircraft.py index 29f5210..1367afd 100644 --- a/src/graphql_schema/entities/resolvers/aircraft.py +++ b/src/graphql_schema/entities/resolvers/aircraft.py @@ -1,24 +1,41 @@ from operator import or_ from typing import Set, Optional from database import models -from graphql_schema.entities.resolvers.base import get_base_resolver +from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver +from graphql_schema.entities.types.types import Aircraft -def get_aircraft_resolver(user_id: int, organization_ids: Optional[Set[int]] = None, aircraft_id: Optional[int] = None): - query = get_base_resolver( - model=models.Aircraft, - order_by=[models.Aircraft.id.desc()], - object_id=aircraft_id, - user_id=user_id if not organization_ids else None - ) - if organization_ids: - query = ( - query.filter( - or_( - models.Aircraft.created_by_id == user_id, - models.Aircraft.organization_id.in_(organization_ids) +class AircraftQueryResolver(BaseQueryResolver): + def __init__(self): + super().__init__(graphql_type=Aircraft, model=models.Aircraft) + + def get_query( + self, + user_id: Optional[int] = None, + object_id: Optional[int] = None, + order_by: Optional[list] = None, + organization_ids: Optional[Set[int]] = None, + *args, + **kwargs, + ): + query = super().get_query( + order_by=[models.Aircraft.id.desc()], + object_id=object_id, + user_id=user_id if not organization_ids else None + ) + if organization_ids: + query = ( + query.filter( + or_( + models.Aircraft.created_by_id == user_id, + models.Aircraft.organization_id.in_(organization_ids) + ) ) ) - ) - return query + return query + + +class AircraftMutationResolver(BaseMutationResolver): + def __init__(self): + super().__init__(graphql_type=Aircraft, model=models.Aircraft) diff --git a/src/graphql_schema/entities/resolvers/base.py b/src/graphql_schema/entities/resolvers/base.py index e41dd1a..e10c883 100644 --- a/src/graphql_schema/entities/resolvers/base.py +++ b/src/graphql_schema/entities/resolvers/base.py @@ -1,53 +1,108 @@ -from typing import Optional, Type +from typing import Optional, Type, T from sqlalchemy import select, or_ +from sqlalchemy.ext.asyncio import AsyncSession from database import models -from dependencies.db import get_session +from database.transaction import get_session -def get_base_resolver( - model: Type[models.BaseModel], - object_id: Optional[int] = None, - user_id: Optional[int] = None, - order_by: Optional[list] = None, - include_public: Optional[bool] = True, -): - query = select(model) +class BaseQueryResolver: - if object_id: - if hasattr(model, "id"): - query = query.filter(model.id == object_id) + def __init__(self, graphql_type, model): + self.graphql_type = graphql_type + self.model = model + + def get_query( + self, + user_id: Optional[int] = None, + object_id: Optional[int] = None, + order_by: Optional[list] = None, + include_public: Optional[bool] = True, + *args, + **kwargs, + ): + query = select(self.model) + + if object_id: + if hasattr(self.model, "id"): + query = query.filter(self.model.id == object_id) + else: + raise AssertionError(f"Model {self.model} has no ID column! Cannot query by ID!") + + if hasattr(self.model, "deleted"): + query = query.filter(self.model.deleted.is_(False)) + + ownership_clause = [] + if hasattr(self.model, "is_public") and include_public: + ownership_clause.append(self.model.is_public.is_(True)) + + if hasattr(self.model, "created_by_id") and user_id: + ownership_clause.append(self.model.created_by_id == user_id) + + if len(ownership_clause) > 1: + query = query.filter(or_(*ownership_clause)) + elif len(ownership_clause) == 1: + query = query.filter(*ownership_clause) + + if order_by: + query = query.order_by(*order_by) + + return query + + async def _get_list(self, query): + async with get_session() as db: + items = (await db.scalars(query)).all() + + return [self.model(**m.as_dict()) for m in items] + + async def _get_one(self, query): + async with get_session() as db: + data = (await db.scalars(query)).one() + return self.model(**data.as_dict()) + + async def get_list(self, user_id: Optional[int] = None, **kwargs) -> list: + query = self.get_query(user_id, **kwargs) + return await self._get_list(query) + + async def get_one(self, id: int, user_id: Optional[int] = None, **kwargs): + query = self.get_query(user_id, object_id=id, **kwargs) + return await self._get_one(query) + + +class BaseMutationResolver: + model: Type[models.BaseModel] + graphql_type: Type[T] = None + + def __init__(self, graphql_type: Type[T], model: Type[models.BaseModel]): + self.graphql_type = graphql_type + self.model = model + + async def delete(self, user_id: int, id: int) -> T: + async with get_session() as db: + query = BaseQueryResolver(self.graphql_type, self.model).get_query(user_id, object_id=id) + model = (await db.scalars(query)).one() + + if hasattr(self.model, "deleted"): + model = await self.model.update(db, obj=model, data=dict(deleted=True)) + else: + await db.delete(model) + + return self.graphql_type(**model.as_dict()) + + async def create(self, data: dict, user_id: Optional[int] = None) -> T: + input_data = {**data} + if hasattr(self.model, "created_by_id"): + input_data['created_by_id'] = user_id + + async with get_session() as db: + model = await self.model.create(db, data=input_data) + return self.graphql_type(**model.as_dict()) + + async def _do_update(self, db: AsyncSession, obj: models.BaseModel | dict, data: dict) -> T: + update_where = {} + if isinstance(obj, models.BaseModel): + update_where['obj'] = obj else: - raise AssertionError(f"Model {model} has no ID column! Cannot query by ID!") + update_where['id'] = obj['id'] - if hasattr(model, "deleted"): - query = query.filter(model.deleted.is_(False)) - - ownership_clause = [] - if hasattr(model, "is_public") and include_public: - ownership_clause.append(model.is_public.is_(True)) - - if hasattr(model, "created_by_id") and user_id: - ownership_clause.append(model.created_by_id == user_id) - - if len(ownership_clause) > 1: - query = query.filter(or_(*ownership_clause)) - elif len(ownership_clause) == 1: - query = query.filter(*ownership_clause) - - if order_by: - query = query.order_by(*order_by) - - return query - - -async def get_list(model, query): - async with get_session() as db: - items = (await db.scalars(query)).all() - - return [model(**m.as_dict()) for m in items] - - -async def get_one(model, query): - async with get_session() as db: - data = (await db.scalars(query)).one() - return model(**data.as_dict()) + model = await self.model.update(db, data=data, **update_where) + return self.graphql_type(**model.as_dict()) diff --git a/src/graphql_schema/entities/resolvers/flight.py b/src/graphql_schema/entities/resolvers/flight.py index a9ff5cf..9fd430d 100644 --- a/src/graphql_schema/entities/resolvers/flight.py +++ b/src/graphql_schema/entities/resolvers/flight.py @@ -1,77 +1,51 @@ import asyncio -from datetime import datetime -from typing import List, Optional, Type -from aiocache import cached +from typing import List, Optional from sqlalchemy import delete, insert from sqlalchemy.ext.asyncio import AsyncSession from strawberry.file_uploads import Upload from background_jobs.elevation import add_terrain_elevation_to_flight +from background_jobs.weather import download_weather from database import models from database.models import flight_has_copilot -from dependencies.db import get_session -from external.weather import weather_api +from database.transaction import get_session from graphql_schema.entities.helpers.combobox import handle_combobox_save -from graphql_schema.entities.resolvers.base import get_base_resolver +from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver from graphql_schema.entities.types.mutation_input import EditFlightInput, TrackItemInput, ComboboxInput from graphql_schema.entities.types.types import Flight -from upload_utils import delete_file, handle_file_upload +from paths import FLIGHT_GPX_TRACK_PATH +from utils.file import delete_file +from utils.upload import handle_file_upload -class BaseMutationResolver: - model: Type[models.BaseModel] - graphql_type = Flight +class FlightQueryResolver(BaseQueryResolver): + def __init__(self): + super().__init__(graphql_type=Flight, model=models.Flight) - @classmethod - async def delete(cls, user_id: int, id: int): - async with get_session() as db: - model = ( - (await db.scalars( - get_base_resolver(cls.model, user_id=user_id, object_id=id) - .filter(cls.model.id == id)) - ) - .one() + def get_query(self, user_id: int, object_id: Optional[int] = None, *args, **kwargs): + query = super().get_query( + user_id, object_id, + order_by=[models.Flight.takeoff_datetime.desc()], + include_public=bool(user_id) + ) + + if kwargs.get('username'): + query = ( + query + .join(models.Flight.created_by) + .filter(models.User.public_username == kwargs['username']) ) - if hasattr(cls.model, "deleted"): - model = await cls.model.update(db, obj=model, data=dict(deleted=True)) - else: - db.delete(model) - - return cls.graphql_type(**model.as_dict()) - - @classmethod - async def _do_update(cls, db: AsyncSession, obj: models.BaseModel | dict, data: dict): - update_where = {} - if isinstance(obj, models.BaseModel): - update_where['obj'] = obj - else: - update_where['id'] = obj['id'] - - model = await cls.model.update(db, data=data, **update_where) - return cls.graphql_type(**model.as_dict()) - - -async def handle_upload_gpx(original_gpx_filename: str, gpx_track: Upload): - path = "/app/uploads/tracks" - - if original_gpx_filename: - delete_file(path + "/" + original_gpx_filename, silent=True) - - return await handle_file_upload(gpx_track, path) + return query class FlightMutationResolver(BaseMutationResolver): - model = models.Flight - graphql_type = Flight + def __init__(self): + super().__init__(Flight, models.Flight) - async def create(self): - pass - - @classmethod - async def update(cls, context, id: int, input: EditFlightInput): + async def update(self, context, id: int, input: EditFlightInput): user_id = context.user_id async with get_session() as db: - flight = (await db.scalars(get_base_resolver(models.Flight, user_id=user_id, object_id=id))).one() + flight = await FlightQueryResolver().get_one(id, user_id) flight_data = flight.as_dict() flight_id = flight.id @@ -84,31 +58,32 @@ class FlightMutationResolver(BaseMutationResolver): ) async with get_session() as db: - if input.landing_airport: - landing_airport = await get_airport(db, input.landing_airport, user_id) - landing_datetime = input.landing_datetime or flight_data['landing_datetime'] - - data['landing_airport_id'] = landing_airport.id - data['landing_datetime'] = landing_datetime - - weather_info = await handle_weather_info( - db, landing_datetime, landing_airport, flight_data['landing_weather_info_id'] - ) - if weather_info: - data['landing_weather_info_id'] = weather_info.id - if input.takeoff_airport: - takeoff_airport = await get_airport(db, input.takeoff_airport, user_id) - takeoff_datetime = input.takeoff_datetime or flight_data['takeoff_datetime'] + takeoff_airport_id = await handle_combobox_save( + db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code", + extra_data={"name": input.takeoff_airport.name} + ) + data['takeoff_airport_id'] = takeoff_airport_id + data['takeoff_datetime'] = input.takeoff_datetime or flight_data['takeoff_datetime'] - data['takeoff_airport_id'] = takeoff_airport.id - data['takeoff_datetime'] = takeoff_datetime + context.background_tasks.add_task( + download_weather, flight_id=id, airport_id=takeoff_airport_id, date_time=data['takeoff_datetime'], + type_="takeoff" + ) - weather_info = await handle_weather_info( - db, takeoff_datetime, takeoff_airport, flight_data['takeoff_weather_info_id'] + if input.landing_airport: + landing_airport_id = await handle_combobox_save( + db, models.Airport, input.landing_airport, user_id, name_column="icao_code", + extra_data={"name": input.landing_airport.name} + ) + + data['landing_airport_id'] = landing_airport_id + data['landing_datetime'] = input.landing_datetime or flight_data['landing_datetime'] + + context.background_tasks.add_task( + download_weather, flight_id=id, airport_id=landing_airport_id, date_time=data['landing_datetime'], + type_="landing" ) - if weather_info: - data['takeoff_weather_info_id'] = weather_info.id if input.aircraft is not None: data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft) @@ -127,46 +102,21 @@ class FlightMutationResolver(BaseMutationResolver): if input.copilots is not None: await db.execute(delete(flight_has_copilot).filter_by(flight_id=flight_id)) - copilots = await handle_copilots_edit(db, input.copilots or [], user_id) + copilots = await asyncio.gather(*[ + handle_combobox_save(db, models.Copilot, copilot, user_id) for copilot in input.copilots + ]) + for copilot_id in copilots: await db.execute(insert(flight_has_copilot).values(flight_id=flight_id, copilot_id=copilot_id)) - return await cls._do_update(db, flight_data, data) + return await self._do_update(db, flight_data, data) -async def handle_weather_info( - db: AsyncSession, date_time: datetime, airport: models.Airport, existing_weather_id: Optional[int] = None -) -> Optional[models.WeatherInfo]: - if not airport.gps_latitude or not airport.gps_longitude: - return None +async def handle_upload_gpx(original_gpx_filename: str, gpx_track: Upload): + if original_gpx_filename: + delete_file(FLIGHT_GPX_TRACK_PATH + "/" + original_gpx_filename, silent=True) - try: - weather = await weather_api.get_weather_for_hour( - date_time.astimezone(), - gps=(airport.gps_latitude, airport.gps_longitude) - ) - except Exception as e: - print(e) - return None - - data = { - "datetime": weather['datetime'], - "qnh": weather['pressure_msl'], - "temperature_surface": weather['temperature_2m'], - "dewpoint_surface": weather['dewpoint_2m'], - "rain": weather['rain'], - "cloudcover_total": weather['cloudcover'], - "cloudcover_low": weather['cloudcover_low'], - "wind_speed_surface": weather['windspeed_10m'], - "wind_direction_surface": weather['winddirection_10m'], - } - - if existing_weather_id: - model = await models.WeatherInfo.update(db, id=existing_weather_id, data=data) - else: - model = await models.WeatherInfo.create(db, data=data) - - return model + return await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH) async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackItemInput], user_id: int): @@ -176,6 +126,7 @@ async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackI for item in track: poi_id = None airport_id = None + if item.point_of_interest: poi_id = await handle_combobox_save( db, models.PointOfInterest, item.point_of_interest, user_id, extra_data={"description": ""} @@ -195,7 +146,7 @@ async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackI "point_of_interest_id": poi_id, "airport_id": airport_id, "order": order, - "landing_duration": item.landing_duration + "landing_duration": item.landing_duration if airport_id else None } ) order += 1 @@ -211,17 +162,3 @@ async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: Combobo "seats": 2, "manufacturer": "", }) - - -@cached() -async def get_airport(db: AsyncSession, input: ComboboxInput, user_id: int): - airport_id = await handle_combobox_save( - db, models.Airport, input, user_id, name_column="icao_code", extra_data={"name": input.name} - ) - - return (await db.scalars(get_base_resolver(models.Airport, object_id=airport_id))).one() - - -async def handle_copilots_edit(db: AsyncSession, copilots: List[ComboboxInput], user_id: int) -> tuple: - cors = [handle_combobox_save(db, models.Copilot, copilot, user_id) for copilot in copilots] - return await asyncio.gather(*cors) diff --git a/src/graphql_schema/entities/types/base.py b/src/graphql_schema/entities/types/base.py index e69de29..7fce6f1 100644 --- a/src/graphql_schema/entities/types/base.py +++ b/src/graphql_schema/entities/types/base.py @@ -0,0 +1,21 @@ +from datetime import datetime +from typing import List + + +class BaseGraphqlInputType: + base_ignored_fields: List[str] = ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] + ignored_fields: List[str] = [] + + def to_dict(self) -> dict: + 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: + continue + + if isinstance(value, datetime): + value = value.astimezone() + + dict_data[key] = value + + return dict_data diff --git a/src/graphql_schema/entities/types/flight.py b/src/graphql_schema/entities/types/flight.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/graphql_schema/entities/types/mutation_input.py b/src/graphql_schema/entities/types/mutation_input.py index 0d5145b..2bee532 100644 --- a/src/graphql_schema/entities/types/mutation_input.py +++ b/src/graphql_schema/entities/types/mutation_input.py @@ -1,8 +1,10 @@ from __future__ import annotations +from datetime import datetime from typing import Optional, List import strawberry from strawberry.file_uploads import Upload from database import models +from graphql_schema.entities.types.base import BaseGraphqlInputType from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input @@ -12,11 +14,51 @@ class ComboboxInput: name: str +@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) +class CreateCopilotInput(BaseGraphqlInputType): + pass + + +@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) +class EditCopilotInput(BaseGraphqlInputType): + pass + + +@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id']) +class CreatePointOfInterestInput(BaseGraphqlInputType): + type: Optional[ComboboxInput] = None + + +@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id']) +class EditPointOfInterestInput(BaseGraphqlInputType): + type: Optional[ComboboxInput] = None + + +@strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"]) +class CreateOrganizationInput(BaseGraphqlInputType): + pass + + +@strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"]) +class EditOrganizationInput(BaseGraphqlInputType): + pass + + +@strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"]) +class CreateEventInput(BaseGraphqlInputType): + pass + + +@strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"]) +class EditEventInput(BaseGraphqlInputType): + pass + + @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ "id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id", - "takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename" + "takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id" ], all_optional=True) -class EditFlightInput: +class EditFlightInput(BaseGraphqlInputType): gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! track: Optional[List[TrackItemInput]] = None copilots: Optional[List[ComboboxInput]] = None @@ -26,19 +68,29 @@ class EditFlightInput: event: Optional[ComboboxInput] = None -@strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ - "id", "aircraft_id", "landing_airport_id", "takeoff_airport_id", "weather_info_takeoff_id", - "weather_info_landing_id", "with_instructor", "has_terrain_elevation" -]) -class CreateFlightInput: +@strawberry.input() +class CreateFlightInput(BaseGraphqlInputType): aircraft: ComboboxInput landing_airport: ComboboxInput takeoff_airport: ComboboxInput + takeoff_datetime: datetime + landing_datetime: datetime @strawberry.input() class TrackItemInput: - # order: int point_of_interest: Optional[ComboboxInput] = None airport: Optional[ComboboxInput] = None landing_duration: Optional[int] = None + + +@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename']) +class CreateAircraftInput(BaseGraphqlInputType): + photo: Optional[Upload] + organization: Optional[ComboboxInput] = None + + +@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['photo_filename']) +class EditAircraftInput(BaseGraphqlInputType): + photo: Optional[Upload] + organization: Optional[ComboboxInput] = None diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py index 58526bb..3ad3179 100644 --- a/src/graphql_schema/entities/types/types.py +++ b/src/graphql_schema/entities/types/types.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime from typing import Optional, Annotated, List import strawberry -from config import API_URL from database import models from decorators.endpoints import authenticated_user_only from external.gpx_parser import GPXParser @@ -17,30 +16,10 @@ from graphql_schema.dataloaders.single_model import ( poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader, cover_photo_loader, airport_weather_info_loader, organizations_dataloader ) -from graphql_schema.entities.airport import Airport from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type -from paths import get_photo_basepath -from upload_utils import file_exists, get_public_url - - -@strawberry_sqlalchemy_type(models.FlightTrack) -class FlightTrack: - point_of_interest: Optional[PointOfInterest] = strawberry.field( - resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) - ) - airport: Optional[Airport] = strawberry.field( - resolver=lambda root: airport_dataloader.load(root.airport_id) - ) - - -@strawberry_sqlalchemy_type(models.PointOfInterestType) -class PointOfInterestType: - pass - - -@strawberry_sqlalchemy_type(models.WeatherInfo) -class WeatherInfo: - pass +from paths import ( + get_public_url, get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH +) @strawberry.type @@ -49,35 +28,6 @@ class Point: lng: float -@strawberry_sqlalchemy_type(models.PointOfInterest) -class PointOfInterest: - type: Optional[PointOfInterestType] = strawberry.field( - resolver=lambda root: poi_type_dataloader.load(root.type_id) - ) - photos: List[Annotated["Photo", strawberry.lazy('.photo')]] = strawberry.field( - resolver=lambda root: poi_photos_dataloader.load(root.id) - ) - flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field( - resolver=lambda root: flight_by_poi_dataloader.load(root.id) - ) - - -@strawberry_sqlalchemy_type(models.Photo) -class Photo: - def resolve_thumb_url(root): - thumbnail = get_photo_basepath(root.flight_id) + "/thumbs/" + root.filename - if not file_exists(thumbnail): - return get_public_url(f"photos/{root.flight_id}/{root.filename}") - - return get_public_url(f"photos/{root.flight_id}/thumbs/{root.filename}") - - url: str = strawberry.field(resolver=lambda root: get_public_url(f"photos/{root.flight_id}/{root.filename}")) - thumbnail_url: str = strawberry.field(resolver=resolve_thumb_url) - point_of_interest: Optional[Annotated["PointOfInterest", strawberry.lazy('.poi')]] = strawberry.field( - resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) - ) - - @strawberry.type class GPXTrack: coordinates: List[Point] @@ -92,6 +42,45 @@ class GPXTrack: avg_altitude: float +@strawberry_sqlalchemy_type(models.Airport) +class Airport: + pass + + +@strawberry_sqlalchemy_type(models.FlightTrack) +class FlightTrack: + point_of_interest: Optional[PointOfInterest] = strawberry.field( + resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) + ) + airport: Optional[Airport] = strawberry.field(resolver=lambda root: airport_dataloader.load(root.airport_id)) + + +@strawberry_sqlalchemy_type(models.PointOfInterestType) +class PointOfInterestType: + pass + + +@strawberry_sqlalchemy_type(models.WeatherInfo) +class WeatherInfo: + pass + + +@strawberry_sqlalchemy_type(models.PointOfInterest) +class PointOfInterest: + type: Optional[PointOfInterestType] = strawberry.field(resolver=lambda root: poi_type_dataloader.load(root.type_id)) + photos: List[Photo] = strawberry.field(resolver=lambda root: poi_photos_dataloader.load(root.id)) + flights: List[Flight] = strawberry.field(resolver=lambda root: flight_by_poi_dataloader.load(root.id)) + + +@strawberry_sqlalchemy_type(models.Photo) +class Photo: + url: str = strawberry.field(resolver=get_photo_url) + thumbnail_url: str = strawberry.field(resolver=get_photo_thumbnail_url) + point_of_interest: Optional[PointOfInterest] = strawberry.field( + resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) + ) + + @strawberry_sqlalchemy_type(models.Flight) class Flight: async def load_gpx_track(root): @@ -99,7 +88,7 @@ class Flight: return None try: - gpx_parser = GPXParser(f"/app/uploads/tracks/{root.gpx_track_filename}") + gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{root.gpx_track_filename}") except OSError: return None @@ -146,9 +135,7 @@ class Flight: @strawberry_sqlalchemy_type(models.Copilot) class Copilot: - flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field( - resolver=lambda root: flights_by_copilot_dataloader.load(root.id) - ) + flights: List[Flight] = strawberry.field(resolver=lambda root: flights_by_copilot_dataloader.load(root.id)) @strawberry_sqlalchemy_type(models.Aircraft) @@ -156,40 +143,24 @@ class Aircraft: photo_url: Optional[str] = strawberry.field( resolver=lambda root: get_public_url(f"aircrafts/{root.photo_filename}") if root.photo_filename else None ) - flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field( - resolver=lambda root: flights_by_aircraft_dataloader.load(root.id) - ) - organization: Optional[Annotated["Organization", strawberry.lazy(".organization")]] = strawberry.field( + flights: List[Flight] = strawberry.field(resolver=lambda root: flights_by_aircraft_dataloader.load(root.id)) + organization: Optional[Organization] = strawberry.field( resolver=lambda root: organizations_dataloader.load(root.organization_id) ) @strawberry_sqlalchemy_type(models.Organization) class Organization: - users: List[Annotated["User", strawberry.lazy(".user")]] = strawberry.field( - resolver=lambda root: users_in_organization_dataloader.load(root.id) - ) - aircrafts: List[Annotated["Aircraft", strawberry.lazy(".aircraft")]] = strawberry.field( + users: List[User] = strawberry.field(resolver=lambda root: users_in_organization_dataloader.load(root.id)) + aircrafts: List[Aircraft] = strawberry.field( resolver=lambda root: aircrafts_from_organization_dataloader.load(root.id) ) @strawberry_sqlalchemy_type(models.User, exclude_fields=['password_hashed']) class User: - async def load_avatar_image_url(root): - if not root.avatar_image_filename: - return None - - return get_public_url(f"profile/{root.id}/{root.avatar_image_filename}") - - async def load_title_image_url(root): - if not root.title_image_filename: - return f"{API_URL}/static/default-title-image.jpg" - - return get_public_url(f"profile/{root.id}/{root.title_image_filename}") - - avatar_image_url: Optional[str] = strawberry.field(resolver=load_avatar_image_url) - title_image_url: str = strawberry.field(resolver=load_title_image_url) + avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root)) + title_image_url: str = strawberry.field(resolver=lambda root: get_title_image_url(root)) organizations: List[Annotated['Organization', strawberry.lazy(".organization")]] = strawberry.field( resolver=lambda root: user_organizations_dataloader.load(root.id) ) diff --git a/src/graphql_schema/entities/user.py b/src/graphql_schema/entities/user.py index b5b9553..e44cada 100644 --- a/src/graphql_schema/entities/user.py +++ b/src/graphql_schema/entities/user.py @@ -1,4 +1,4 @@ -from typing import Optional, TYPE_CHECKING +from typing import Optional import strawberry from graphql import GraphQLError from passlib.hash import bcrypt @@ -7,12 +7,11 @@ from strawberry.file_uploads import Upload from database import models from decorators.endpoints import authenticated_user_only from decorators.error_logging import error_logging -from dependencies.db import get_session -from upload_utils import handle_file_upload, delete_file, resize_image +from database.transaction import get_session from graphql_schema.entities.types.types import User - -if TYPE_CHECKING: - pass +from utils.file import delete_file +from utils.image import resize_image +from utils.upload import handle_file_upload @strawberry.type diff --git a/src/graphql_schema/mutation.py b/src/graphql_schema/mutation.py index 9d0ecf2..2afe2cb 100644 --- a/src/graphql_schema/mutation.py +++ b/src/graphql_schema/mutation.py @@ -1,31 +1,24 @@ from strawberry.tools import merge_types -from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation +from graphql_schema.entities.aircraft import AircraftMutation from graphql_schema.entities.copilot import CreateCopilotMutation, EditCopilotMutation -from graphql_schema.entities.event import CreateEventMutation, EditEventMutation +from graphql_schema.entities.event import EventMutation from graphql_schema.entities.flight import FlightMutation -from graphql_schema.entities.organization import ( - CreateOrganizationMutation, EditOrganizationMutation, OrganizationUserMutation -) +from graphql_schema.entities.organization import OrganizationUserMutation, OrganizationMutation from graphql_schema.entities.photo import UploadPhotoMutation, DeletePhotoMutation, EditPhotoMutation -from graphql_schema.entities.poi import CreatePointOfInterestMutation, EditPointOfInterestMutation +from graphql_schema.entities.poi import PointOfInterestMutation from graphql_schema.entities.user import EditUserMutation Mutation = merge_types("Mutation", ( - CreateAircraftMutation, - EditAircraftMutation, - DeleteAircraftMutation, + AircraftMutation, FlightMutation, UploadPhotoMutation, EditPhotoMutation, DeletePhotoMutation, - CreatePointOfInterestMutation, - EditPointOfInterestMutation, + PointOfInterestMutation, CreateCopilotMutation, EditCopilotMutation, EditUserMutation, - CreateEventMutation, - EditEventMutation, - CreateOrganizationMutation, - EditOrganizationMutation, + EventMutation, + OrganizationMutation, OrganizationUserMutation, )) diff --git a/src/graphql_schema/sqlalchemy_to_strawberry_type.py b/src/graphql_schema/sqlalchemy_to_strawberry_type.py index d1c6b89..727c043 100644 --- a/src/graphql_schema/sqlalchemy_to_strawberry_type.py +++ b/src/graphql_schema/sqlalchemy_to_strawberry_type.py @@ -1,10 +1,10 @@ import typing -from datetime import datetime from typing import List, Optional import strawberry import sqlalchemy from sqlalchemy import Column from database.models import BaseModel +from graphql_schema.entities.types.base import BaseGraphqlInputType def get_columns_from_model(model: BaseModel, exclude_fields: List[str]) -> List[typing.Tuple[str, Column]]: @@ -49,20 +49,7 @@ def strawberry_sqlalchemy_input( if exclude_fields is None: exclude_fields = [] - ignored_fields = exclude_fields + ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] - - def to_dict(self): - dict_data = {} - for key, _ in get_columns_from_model(model, ignored_fields): - value = getattr(self, key) - if value is None: - continue - - if isinstance(value, datetime): - value = value.astimezone() - - dict_data[key] = value - return dict_data + ignored_fields = exclude_fields + BaseGraphqlInputType.base_ignored_fields def wrapper(cls): annotations = get_annotations_for_scalars( @@ -72,7 +59,6 @@ def strawberry_sqlalchemy_input( ) cls.__annotations__.update(annotations) - cls.to_dict = to_dict for col, col_type in annotations.items(): try: diff --git a/src/logger.py b/src/logger.py new file mode 100644 index 0000000..f4bd5bc --- /dev/null +++ b/src/logger.py @@ -0,0 +1,22 @@ +import logging +import sys + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + +# Create handlers for logging to the standard output and a file +stdoutHandler = logging.StreamHandler(stream=sys.stdout) + +# Set the log levels on the handlers +stdoutHandler.setLevel(logging.DEBUG) + +# Create a log format using Log Record attributes +fmt = logging.Formatter( + "%(name)s: %(asctime)s | %(levelname)s | %(filename)s:%(lineno)s | %(process)d >>> %(message)s" +) + +# Set the log format on each handler +stdoutHandler.setFormatter(fmt) + +# Add each handler to the Logger object +log.addHandler(stdoutHandler) diff --git a/src/paths.py b/src/paths.py index 4dd974d..2a7bf1e 100644 --- a/src/paths.py +++ b/src/paths.py @@ -1,7 +1,41 @@ +import os +from typing import Optional +from config import API_URL + PHOTO_BASE_PATH = "" AIRCRAFT_BASE_PATH = "" FLIGHT_BASE_PATH = "" +FLIGHT_GPX_TRACK_PATH = "/app/uploads/tracks" +AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/" def get_photo_basepath(flight_id: int) -> str: return f"/app/uploads/photos/{flight_id}" + + +def get_public_url(filename: Optional[str]) -> str: + return f"{API_URL}/uploads/{filename}" if filename else None + + +def get_photo_url(root) -> str: + return get_public_url(f"photos/{root.flight_id}/{root.filename}") + + +def get_photo_thumbnail_url(root) -> str: + thumbnail = get_photo_basepath(root.flight_id) + "/thumbs/" + root.filename + if not os.path.isfile(thumbnail): + # TODO: logovani + return get_public_url(f"photos/{root.flight_id}/{root.filename}") + + return get_public_url(f"photos/{root.flight_id}/thumbs/{root.filename}") + + +def get_avatar_url(user) -> str: + return get_public_url(f"profile/{user.id}/{user.avatar_image_filename}") if user.avatar_image_filename else None + + +def get_title_image_url(user): + if not user.title_image_filename: + return f"{API_URL}/static/default-title-image.jpg" + + return get_public_url(f"profile/{user.id}/{user.title_image_filename}") diff --git a/src/scripts/elevation.py b/src/scripts/elevation.py index 46f316f..b68ec63 100644 --- a/src/scripts/elevation.py +++ b/src/scripts/elevation.py @@ -2,6 +2,8 @@ import asyncio import sys from sqlalchemy import select +from paths import FLIGHT_GPX_TRACK_PATH + sys.path.insert(0, "/app/src") from database import async_session, models # noqa @@ -45,7 +47,7 @@ async def add_elevation_to_tracks(): return for flight in flights: - gpx_file = f"/app/uploads/tracks/{flight.gpx_track_filename}" + gpx_file = f"{FLIGHT_GPX_TRACK_PATH}/{flight.gpx_track_filename}" gpx = GPXParser(gpx_file) coordinates = await gpx.get_coordinates() @@ -53,7 +55,7 @@ async def add_elevation_to_tracks(): gpx_with_elevation = gpx.add_terrain_elevation(elevation) output_name = f"terrain_{flight.gpx_track_filename[30:]}" - gpx.write(gpx_with_elevation, output=f"/app/uploads/tracks/{output_name}") + gpx.write(gpx_with_elevation, output=f"{FLIGHT_GPX_TRACK_PATH}/{output_name}") await models.Flight.update( db_session=session, obj=flight, data={ "has_terrain_elevation": True, diff --git a/src/utils/file.py b/src/utils/file.py new file mode 100644 index 0000000..d5634d7 --- /dev/null +++ b/src/utils/file.py @@ -0,0 +1,14 @@ +import os + + +def delete_file(path: str, silent: bool = False): + try: + os.remove(path) + except Exception: + if not silent: + raise + + +def check_directories(path: str): + if not os.path.isdir(path): + os.makedirs(path) diff --git a/src/utils/gps.py b/src/utils/gps.py new file mode 100644 index 0000000..b96ccb8 --- /dev/null +++ b/src/utils/gps.py @@ -0,0 +1,6 @@ +from typing import Tuple + + +def gps_to_decimal(input: Tuple[float, float, float]) -> float: + d, m, s = input + return d + (m / 60.0) + (s / 3600.0) diff --git a/src/upload_utils.py b/src/utils/image.py similarity index 62% rename from src/upload_utils.py rename to src/utils/image.py index 1ba7022..b60f638 100644 --- a/src/upload_utils.py +++ b/src/utils/image.py @@ -1,42 +1,9 @@ -import os -import uuid from datetime import datetime -from typing import Optional, Tuple +from typing import Optional import exif from PIL import Image, UnidentifiedImageError -from strawberry.file_uploads import Upload -from config import API_URL - - -def get_public_url(filename: Optional[str]): - return f"{API_URL}/uploads/{filename}" if filename else None - - -def check_directories(path: str): - if not os.path.isdir(path): - os.makedirs(path) - - -def file_exists(path: str): - return os.path.isfile(path) - - -async def handle_file_upload(file: Upload, path: str): - check_directories(path) - - filename = f"{uuid.uuid4()}-{file.filename}" - - content = await file.read() - image = open(path + "/" + filename, "wb") - image.write(content) - image.close() - - return filename - - -def gps_to_decimal(input: Tuple[float, float, float]) -> float: - d, m, s = input - return d + (m / 60.0) + (s / 3600.0) +from utils.file import check_directories +from utils.gps import gps_to_decimal async def parse_exif_info(path: str, filename: str) -> dict: @@ -101,21 +68,3 @@ async def rotate_image( img = img.rotate(angle, Image.LANCZOS, expand=True) check_directories(dest_path) img.save(f"{dest_path}/{dest_filename}", 'JPEG', quality=100) - - -async def generate_thumbnail(path: str, filename: str, quality: int = 90): - return await resize_image( - path, filename, - new_width=300, - dest_path=f"{path}/thumbs/", - dest_filename=filename, - quality=quality - ) - - -def delete_file(path: str, silent: bool = False): - try: - os.remove(path) - except Exception: - if not silent: - raise diff --git a/src/utils/upload.py b/src/utils/upload.py new file mode 100644 index 0000000..3d6e12c --- /dev/null +++ b/src/utils/upload.py @@ -0,0 +1,17 @@ +import uuid +from strawberry.file_uploads import Upload + +from utils.file import check_directories + + +async def handle_file_upload(file: Upload, path: str) -> str: + check_directories(path) + + filename = f"{uuid.uuid4()}-{file.filename}" + + content = await file.read() + image = open(path + "/" + filename, "wb") + image.write(content) + image.close() + + return filename