diff --git a/alembic/versions/20230928-224352_add_airports_to_track_956d295689bf.py b/alembic/versions/20230928-224352_add_airports_to_track_956d295689bf.py new file mode 100644 index 0000000..2f2bd72 --- /dev/null +++ b/alembic/versions/20230928-224352_add_airports_to_track_956d295689bf.py @@ -0,0 +1,38 @@ +"""add airports to track + +Revision ID: 956d295689bf +Revises: 8b0c020dc0c4 +Create Date: 2023-09-28 22:43:52.286599 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '956d295689bf' +down_revision = '8b0c020dc0c4' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight_track', sa.Column('airport_id', sa.Integer(), nullable=True)) + op.add_column('flight_track', sa.Column('landing_duration', sa.Integer(), nullable=True)) + op.alter_column('flight_track', 'point_of_interest_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.create_foreign_key(None, 'flight_track', 'airport', ['airport_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'flight_track', type_='foreignkey') + op.alter_column('flight_track', 'point_of_interest_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.drop_column('flight_track', 'landing_duration') + op.drop_column('flight_track', 'airport_id') + # ### end Alembic commands ### diff --git a/src/background_jobs/elevation.py b/src/background_jobs/elevation.py index 9c2724c..fec60c2 100644 --- a/src/background_jobs/elevation.py +++ b/src/background_jobs/elevation.py @@ -5,7 +5,7 @@ from external.elevation import elevation_api from external.gpx_parser import GPXParser -async def add_terrain_elevation_to_flight(flight: dict, gpx_filename: str): +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}") @@ -20,7 +20,7 @@ async def add_terrain_elevation_to_flight(flight: dict, gpx_filename: str): async with get_session() as db: await models.Flight.update( db, {"gpx_track_filename": output_name, "has_terrain_elevation": True}, - id=flight['id']) + id=flight_id) except ClientResponseError as e: print(e) diff --git a/src/database/models.py b/src/database/models.py index 21dc840..9d7cdff 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -187,11 +187,14 @@ class FlightTrack(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False) - point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=False) + point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True) + airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True) + landing_duration: Mapped[int] = mapped_column(Integer, nullable=True) order: Mapped[int] = mapped_column(Integer) flight: Mapped['Flight'] = relationship() point_of_interest: Mapped['PointOfInterest'] = relationship() + airport: Mapped['Airport'] = relationship() class WeatherInfo(BaseModel): diff --git a/src/external/gpx_parser.py b/src/external/gpx_parser.py index f331782..55cbbba 100644 --- a/src/external/gpx_parser.py +++ b/src/external/gpx_parser.py @@ -23,7 +23,7 @@ class GPXParser: @cached() async def get_times(self): - nodes = self.run_xpath("//gpx:time") + nodes = self.run_xpath("//gpx:trkpt/gpx:time") return [datetime.fromisoformat(node.text).astimezone() for node in nodes] @cached() diff --git a/src/external/weather.py b/src/external/weather.py index 33f7a55..79fbcc8 100644 --- a/src/external/weather.py +++ b/src/external/weather.py @@ -57,3 +57,6 @@ class Weather: result_data['datetime'] = datetime.datetime.strptime(data['hourly']['time'][idx], "%Y-%m-%dT%H:%M") return result_data + + +weather_api = Weather() diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 7c0b194..96ab080 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -1,38 +1,23 @@ -from typing import List, Optional, Annotated, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING 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_type, strawberry_sqlalchemy_input -from upload_utils import handle_file_upload, delete_file, get_public_url +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input +from upload_utils import handle_file_upload, delete_file 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 ..dataloaders.multi_models import flights_by_aircraft_dataloader -from ..dataloaders.single_model import organizations_dataloader -from ..types import ComboboxInput +from graphql_schema.entities.types.mutation_input import ComboboxInput +from graphql_schema.entities.types.types import Aircraft if TYPE_CHECKING: - from .flight import Flight - from .organization import Organization + pass AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/" -@strawberry_sqlalchemy_type(models.Aircraft) -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( - resolver=lambda root: organizations_dataloader.load(root.organization_id) - ) - - @strawberry.type class AircraftQueries: @strawberry.field() diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index 28c714d..2493c43 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -1,21 +1,14 @@ -from typing import List, Annotated, TYPE_CHECKING +from typing import List, TYPE_CHECKING 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_type, strawberry_sqlalchemy_input +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input from .resolvers.base import get_base_resolver, get_list, get_one -from ..dataloaders.multi_models import flights_by_copilot_dataloader +from graphql_schema.entities.types.types import Copilot if TYPE_CHECKING: - from .flight import 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) - ) + pass @strawberry.type diff --git a/src/graphql_schema/entities/event.py b/src/graphql_schema/entities/event.py index 10e420e..b2db6b5 100644 --- a/src/graphql_schema/entities/event.py +++ b/src/graphql_schema/entities/event.py @@ -1,21 +1,14 @@ -from typing import List, Annotated, TYPE_CHECKING +from typing import List, TYPE_CHECKING 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_type, strawberry_sqlalchemy_input +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input from .resolvers.base import get_base_resolver, get_list, get_one -from ..dataloaders.multi_models import flights_by_event_dataloader +from graphql_schema.entities.types.types import Event if TYPE_CHECKING: - from .flight import Flight - - -@strawberry_sqlalchemy_type(models.Event) -class Event: - flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field( - resolver=lambda root: flights_by_event_dataloader.load(root.id) - ) + pass @strawberry.type diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index 43cd9f5..b9e6551 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -1,138 +1,19 @@ import asyncio -from datetime import timedelta, datetime -from typing import List, Optional, Annotated, TYPE_CHECKING +from typing import List, Optional import strawberry from fastapi import HTTPException -from sqlalchemy import select, insert, delete +from sqlalchemy import select from starlette.status import HTTP_401_UNAUTHORIZED -from strawberry.file_uploads import Upload -from background_jobs.elevation import add_terrain_elevation_to_flight from database import models -from database.models import flight_has_copilot from decorators.endpoints import authenticated_user_only from decorators.error_logging import error_logging from dependencies.db import get_session -from external.gpx_parser import GPXParser -from graphql_schema.entities.aircraft import Aircraft -from graphql_schema.entities.airport import Airport -from graphql_schema.entities.photo import Photo -from graphql_schema.entities.poi import PointOfInterest -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input -from upload_utils import get_public_url -from .helpers.flight import ( - handle_aircraft_save, handle_track_edit, handle_copilots_edit, handle_weather_info, get_airports, - handle_upload_gpx, handle_airport_changed, handle_combobox_save +from graphql_schema.entities.resolvers.flight import ( + handle_aircraft_save, handle_weather_info, FlightMutationResolver, get_airport, ) +from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput from .resolvers.base import get_list, get_one -from ..dataloaders.multi_models import flight_copilots_dataloader, flight_track_dataloader, photos_dataloader -from ..dataloaders.single_model import ( - poi_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader, cover_photo_loader, - airport_weather_info_loader -) -from ..types import ComboboxInput - -if TYPE_CHECKING: - from .copilot import Copilot - from .event import Event - - -@strawberry_sqlalchemy_type(models.FlightTrack) -class FlightTrack: - point_of_interest: PointOfInterest = strawberry.field( - resolver=lambda root: poi_dataloader.load(root.point_of_interest_id) - ) - - -@strawberry_sqlalchemy_type(models.WeatherInfo) -class WeatherInfo: - pass - - -@strawberry.type -class Point: - lat: float - lng: float - - -@strawberry.type -class GPXTrack: - coordinates: List[Point] - speed: List[float] - altitude: List[float] - magnetic_variation: List[float] - terrain_elevation: List[float] - time: List[datetime] - max_speed: float - avg_speed: float - max_altitude: float - avg_altitude: float - - -@strawberry_sqlalchemy_type(models.Flight) -class Flight: - def duration_min_calculated(root): - if root.duration_total: - return root.duration_total - - if root.takeoff_datetime and root.landing_datetime: - diff: timedelta = root.landing_datetime - root.takeoff_datetime - return diff.seconds / 60 - - return 0 - - async def load_gpx_track(root): - if not root.gpx_track_filename: - return None - - try: - gpx_parser = GPXParser(f"/app/uploads/tracks/{root.gpx_track_filename}") - except OSError: - return None - - return GPXTrack( - coordinates=[Point(**point) for point in await gpx_parser.get_coordinates()], - speed=await gpx_parser.get_speed(), - altitude=await gpx_parser.get_altitude(), - terrain_elevation=await gpx_parser.get_terrain_elevation(), - time=await gpx_parser.get_times(), - max_speed=await gpx_parser.get_max_speed(), - avg_speed=await gpx_parser.get_avg_speed(), - max_altitude=await gpx_parser.get_max_altitude(), - avg_altitude=await gpx_parser.get_avg_altitude(), - magnetic_variation=await gpx_parser.get_magnetic_variation(), - ) - - def load_gpx_track_url(root): - if not root.gpx_track_filename: - return None - - return get_public_url(f"/tracks/{root.gpx_track_filename}") - - @authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[]) - async def load_copilots(root): - return await flight_copilots_dataloader.load(root.id) - - @authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[]) - async def load_event(root): - return await event_dataloader.load(root.event_id) - - duration_min_calculated: int = strawberry.field(resolver=duration_min_calculated) - copilots: Optional[List[Annotated["Copilot", strawberry.lazy(".copilot")]]] = strawberry.field(resolver=load_copilots) # noqa - event: Optional[Annotated["Event", strawberry.lazy(".event")]] = strawberry.field(resolver=load_event) - aircraft: Aircraft = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id)) - takeoff_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.takeoff_airport_id)) - landing_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.landing_airport_id)) - cover_photo: Optional[Photo] = strawberry.field(resolver=lambda root: cover_photo_loader.load(root.id)) - track: List[FlightTrack] = strawberry.field(resolver=lambda root: flight_track_dataloader.load(root.id)) - takeoff_weather_info: Optional[WeatherInfo] = strawberry.field( - resolver=lambda root: airport_weather_info_loader.load(root.takeoff_weather_info_id) - ) - landing_weather_info: Optional[WeatherInfo] = strawberry.field( - resolver=lambda root: airport_weather_info_loader.load(root.landing_weather_info_id) - ) - photos: List[Photo] = strawberry.field(resolver=lambda root: photos_dataloader.load(root.id)) - gpx_track_url: Optional[str] = strawberry.field(resolver=load_gpx_track_url) # TODO: odstranit - gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track) +from graphql_schema.entities.types.types import Flight def get_base_query(user_id: Optional[int], username: Optional[str] = None, is_auth: bool = False): @@ -186,25 +67,15 @@ class FlightQueries: @strawberry.type -class CreateFlightMutation: - @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: - aircraft: ComboboxInput - landing_airport: ComboboxInput - takeoff_airport: ComboboxInput - +class FlightMutation: @strawberry.mutation @authenticated_user_only() async def create_flight(self, info, input: CreateFlightInput) -> Flight: data = input.to_dict() - async with get_session() as db: - takeoff_airport, landing_airport = await get_airports( - db, input.takeoff_airport, input.landing_airport, info.context.user_id, - ) + 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) aircraft_id = await handle_aircraft_save(db, info.context.user_id, input.aircraft) weather_takeoff, weather_landing = await asyncio.gather( @@ -225,112 +96,12 @@ class CreateFlightMutation: }) return Flight(**flight.as_dict()) - -@strawberry.type -class EditFlightMutation: - @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" - ], all_optional=True) - class EditFlightInput: - gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! - track: Optional[List[ComboboxInput]] = None - copilots: Optional[List[ComboboxInput]] = None - aircraft: Optional[ComboboxInput] = None - landing_airport: Optional[ComboboxInput] = None - takeoff_airport: Optional[ComboboxInput] = None - event: Optional[ComboboxInput] = None - @strawberry.mutation @authenticated_user_only() async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight: - user_id = info.context.user_id - - async with get_session() as db: - - flight = (await db.scalars( - get_base_query(user_id=user_id, is_auth=bool(user_id)).filter(models.Flight.id == id) - )).one() - - data = input.to_dict() - - # TODO: nahravani a zpracovani presunout mimo DB transakci! - if input.gpx_track is not None: - data['gpx_track_filename'] = await handle_upload_gpx(flight, input.gpx_track) - info.context.background_tasks.add_task( - add_terrain_elevation_to_flight, flight=flight.as_dict(), gpx_filename=data['gpx_track_filename'] - ) - - if input.takeoff_airport and input.landing_airport: - takeoff_airport, landing_airport = await get_airports( - db, input.takeoff_airport, input.landing_airport, info.context.user_id, - ) - - if ( - (input.takeoff_airport and input.takeoff_airport.id != flight.takeoff_airport_id) or - (data.get('takeoff_datetime') and data.get('takeoff_datetime') != flight.takeoff_datetime) - ): - await handle_airport_changed( - db, - flight, - takeoff_airport, - type_="takeoff", - input_datetime=data.get('takeoff_datetime') - ) - - if ( - (input.landing_airport and input.landing_airport.id != flight.landing_airport_id) or - (data.get('landing_datetime') and data.get('landing_datetime') != flight.takeoff_datetime) - ): - await handle_airport_changed( - db, - flight, - landing_airport, - type_="landing", - input_datetime=data.get('landing_datetime') - ) - # ///////////// konec editace s letistem - je to hnusny - - if input.aircraft is not None: - data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft) - - if input.event is not None: - data['event_id'] = await handle_combobox_save( - db, - model=models.Event, - input=input.event, - extra_data={"description": "", "is_public": False}, - user_id=info.context.user_id - ) - - if input.track is not None: - await handle_track_edit(db=db, flight=flight, track=input.track, user_id=user_id) - - copilots = await handle_copilots_edit(db, input.copilots or [], user_id) - await db.execute(delete(flight_has_copilot).filter_by(flight_id=flight.id)) - for copilot_id in copilots: - await db.execute(insert(flight_has_copilot).values(flight_id=flight.id, copilot_id=copilot_id)) - - updated_flight = await models.Flight.update(db, obj=flight, data=data) - - return Flight(**updated_flight.as_dict()) - - -@strawberry.type -class DeleteFlightMutation: + return await FlightMutationResolver.update(info.context, id, input) @strawberry.mutation @authenticated_user_only() async def delete_flight(self, info, id: int) -> Flight: - async with get_session() as db: - flight = ( - (await db.scalars( - get_base_query(user_id=info.context.user_id, is_auth=True) - .filter(models.Flight.id == id)) - ) - .one() - ) - - updated_flight = await models.Flight.update(db, obj=flight, data=dict(deleted=True)) - - return Flight(**updated_flight.as_dict()) + return await FlightMutationResolver.delete(info.context.user_id, id) diff --git a/src/graphql_schema/entities/helpers/combobox.py b/src/graphql_schema/entities/helpers/combobox.py index ec3979d..ccdb94f 100644 --- a/src/graphql_schema/entities/helpers/combobox.py +++ b/src/graphql_schema/entities/helpers/combobox.py @@ -1,7 +1,7 @@ from typing import Type, Optional from sqlalchemy.ext.asyncio import AsyncSession from database import models -from graphql_schema.types import ComboboxInput +from graphql_schema.entities.types.mutation_input import ComboboxInput async def handle_combobox_save( diff --git a/src/graphql_schema/entities/helpers/flight.py b/src/graphql_schema/entities/helpers/flight.py deleted file mode 100644 index e0a3d74..0000000 --- a/src/graphql_schema/entities/helpers/flight.py +++ /dev/null @@ -1,156 +0,0 @@ -import asyncio -from datetime import datetime -from typing import List, Literal, Optional, Tuple -from sqlalchemy import select, delete -from sqlalchemy.ext.asyncio import AsyncSession -from strawberry.file_uploads import Upload -from database import models -from external.weather import Weather -from graphql_schema.entities.helpers.combobox import handle_combobox_save -from graphql_schema.types import ComboboxInput -from upload_utils import delete_file, handle_file_upload - -weather_api = Weather() - - -async def handle_weather_info( - db: AsyncSession, date_time: datetime, airport: models.Airport -) -> Optional[models.WeatherInfo]: - if not airport.gps_latitude or not airport.gps_longitude: - return None - - 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 - model = models.WeatherInfo(**{ - "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'], - }) - db.add(model) - - return model - - -async def handle_track_edit(db: AsyncSession, flight: models.Flight, track: List[ComboboxInput], user_id: int): - await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight.id)) - - existing_poi_ids = [i.id for i in track if i.id] - poi_query = ( - select(models.PointOfInterest) - .filter(models.PointOfInterest.created_by_id == user_id) - .filter(models.PointOfInterest.id.in_(existing_poi_ids)) - ) - pois = (await db.scalars(poi_query)).all() - poi_map = {poi.id: poi for poi in pois} - - order = 0 - for item in track: - poi_object = None - if item.id: - poi_object = poi_map.get(item.id) - - if not poi_object: - poi_object = await models.PointOfInterest.create( - db, - data=dict(created_by_id=user_id, name=item.name, description="") - ) - await db.flush() - - await models.FlightTrack.create( - db, - data={ - "flight_id": flight.id, - "point_of_interest_id": poi_object.id, - "order": order - } - ) - order += 1 - - -async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: ComboboxInput): - return await handle_combobox_save( - db, models.Aircraft, aircraft, user_id, - name_column="call_sign", - extra_data={ - "description": "", - "model": "", - "seats": 2, - "manufacturer": "", - }) - - -async def get_airports( - db, takeoff_airport: ComboboxInput, landing_airport: ComboboxInput, user_id: int -) -> Tuple[models.Airport, models.Airport]: - takeoff_airport_id = await handle_combobox_save( - db, models.Airport, takeoff_airport, user_id, - name_column="icao_code", extra_data={"name": takeoff_airport.name} - ) - - if landing_airport.id != takeoff_airport_id or landing_airport.name != takeoff_airport.name: - landing_airport_id = await handle_combobox_save( - db, models.Airport, landing_airport, user_id, - name_column="icao_code", - extra_data={"name": landing_airport.name} - ) - else: - landing_airport_id = takeoff_airport_id - - takeoff_airport = (await db.scalars( - select(models.Airport).filter(models.Airport.id == takeoff_airport_id) - )).one() - - if takeoff_airport_id == landing_airport_id: - landing_airport = takeoff_airport - else: - landing_airport = (await db.scalars( - select(models.Airport).filter(models.Airport.id == landing_airport_id) - )).one() - - return takeoff_airport, landing_airport - - -async def handle_airport_changed( - db, flight: models.Flight, airport: models.Airport, type_: Literal['takeoff', 'landing'], - input_datetime: Optional[datetime] -): - flight_datetime = getattr(flight, f"{type_}_datetime") - if input_datetime and input_datetime != flight_datetime: - existing_weather_id = getattr(flight, f"{type_}_weather_info_id") - if existing_weather_id: - # db.delete(delete()) - pass - - weather = await handle_weather_info(db, input_datetime, airport) - await db.flush() - if weather: - setattr(flight, f"{type_}_weather_info_id", weather.id) - - setattr(flight, f"{type_}_airport_id", airport.id) - setattr(flight, f"{type_}_datetime", input_datetime) - - -async def handle_upload_gpx(flight: models.Flight, gpx_track: Upload): - path = "/app/uploads/tracks" - - if flight.gpx_track_filename: - delete_file(path + "/" + flight.gpx_track_filename, silent=True) - - return await handle_file_upload(gpx_track, path) - - -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/organization.py b/src/graphql_schema/entities/organization.py index 929a646..9e6626c 100644 --- a/src/graphql_schema/entities/organization.py +++ b/src/graphql_schema/entities/organization.py @@ -1,4 +1,4 @@ -from typing import List, Annotated, TYPE_CHECKING +from typing import List, TYPE_CHECKING import strawberry from sqlalchemy import delete from sqlalchemy.dialects.mysql import insert @@ -6,23 +6,12 @@ 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_type, strawberry_sqlalchemy_input +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input from .resolvers.base import get_base_resolver, get_list, get_one -from ..dataloaders.multi_models import users_in_organization_dataloader, aircrafts_from_organization_dataloader +from graphql_schema.entities.types.types import Organization if TYPE_CHECKING: - from .user import User - from .aircraft import Aircraft - - -@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( - resolver=lambda root: aircrafts_from_organization_dataloader.load(root.id) - ) + pass @strawberry.type diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index 07348cf..e99ef8b 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -1,52 +1,23 @@ import asyncio -from typing import List, Optional, Annotated, TYPE_CHECKING +from typing import List, Optional import strawberry -from sqlalchemy import select, update +from sqlalchemy import update from strawberry.file_uploads import Upload from background_jobs.elevation import add_terrain_elevation_to_photo 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_type -from graphql_schema.types import ComboboxInput +from graphql_schema.entities.types.types import Photo +from paths import get_photo_basepath from upload_utils import ( - get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists, resize_image, + 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 ..dataloaders.single_model import poi_dataloader - -if TYPE_CHECKING: - from .poi import PointOfInterest +from graphql_schema.entities.types.mutation_input import ComboboxInput -@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) - ) - - -def get_base_query(user_id: int): - return ( - select(models.Photo) - .filter(models.Photo.created_by_id == user_id) - .order_by(models.Photo.id.desc()) - ) - - -def get_photo_basepath(flight_id: int) -> str: - return f"/app/uploads/photos/{flight_id}" @strawberry.type @@ -173,9 +144,9 @@ class DeletePhotoMutation: @strawberry.mutation() @authenticated_user_only() async def delete_photo(self, info, id: int) -> Photo: - query = get_base_query(info.context.user_id) + 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.filter(models.Photo.id == id))).one() + photo_model = (await db.scalars(query)).one() await db.delete(photo_model) photo = Photo(**photo_model.as_dict()) diff --git a/src/graphql_schema/entities/poi.py b/src/graphql_schema/entities/poi.py index 261dfa3..0209929 100644 --- a/src/graphql_schema/entities/poi.py +++ b/src/graphql_schema/entities/poi.py @@ -1,33 +1,13 @@ -from typing import List, Optional, TYPE_CHECKING, Annotated +from typing import List, Optional import strawberry -from sqlalchemy import select, or_ from database import models from decorators.endpoints import authenticated_user_only from dependencies.db import get_session from graphql_schema.entities.helpers.combobox import handle_combobox_save -from graphql_schema.entities.poi_type import PointOfInterestType -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input -from graphql_schema.types import ComboboxInput +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input +from graphql_schema.entities.types.types import PointOfInterest from .resolvers.base import get_base_resolver, get_list, get_one -from ..dataloaders.multi_models import flight_by_poi_dataloader, poi_photos_dataloader -from ..dataloaders.single_model import poi_type_dataloader - -if TYPE_CHECKING: - from .flight import Flight - from .photo import Photo - - -@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) - ) +from graphql_schema.entities.types.mutation_input import ComboboxInput @strawberry.type diff --git a/src/graphql_schema/entities/poi_type.py b/src/graphql_schema/entities/poi_type.py index 6c987c1..fef4f8e 100644 --- a/src/graphql_schema/entities/poi_type.py +++ b/src/graphql_schema/entities/poi_type.py @@ -3,12 +3,7 @@ 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.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type - - -@strawberry_sqlalchemy_type(models.PointOfInterestType) -class PointOfInterestType: - pass +from graphql_schema.entities.types.types import PointOfInterestType @strawberry.type diff --git a/src/graphql_schema/entities/resolvers/flight.py b/src/graphql_schema/entities/resolvers/flight.py new file mode 100644 index 0000000..e303448 --- /dev/null +++ b/src/graphql_schema/entities/resolvers/flight.py @@ -0,0 +1,225 @@ +import asyncio +from datetime import datetime +from typing import List, Optional, Type + +from aiocache import cached +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 database import models +from database.models import flight_has_copilot +from dependencies.db import get_session +from external.weather import weather_api +from graphql_schema.entities.helpers.combobox import handle_combobox_save +from graphql_schema.entities.resolvers.base import get_base_resolver +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 + + +class BaseMutationResolver: + model: Type[models.BaseModel] + graphql_type = 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() + ) + + 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) + + +class FlightMutationResolver(BaseMutationResolver): + model = models.Flight + graphql_type = Flight + + async def create(self): + pass + + @classmethod + async def update(cls, 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_data = flight.as_dict() + flight_id = flight.id + + data = input.to_dict() + + if input.gpx_track is not None: + data['gpx_track_filename'] = await handle_upload_gpx(flight_data['gpx_track_filename'], input.gpx_track) + context.background_tasks.add_task( + add_terrain_elevation_to_flight, flight_id=id, gpx_filename=data['gpx_track_filename'] + ) + + 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'] + + data['takeoff_airport_id'] = takeoff_airport.id + data['takeoff_datetime'] = takeoff_datetime + + weather_info = await handle_weather_info(db, takeoff_datetime, takeoff_airport, flight_data['takeoff_weather_info_id']) + 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) + + if input.event is not None: + data['event_id'] = await handle_combobox_save( + db, + model=models.Event, + input=input.event, + extra_data={"description": "", "is_public": False}, + user_id=context.user_id + ) + + if input.track is not None: + await handle_track_edit(db=db, flight_id=flight_id, track=input.track, user_id=user_id) + + 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) + 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) + + +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 + + 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 + + +async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackItemInput], user_id: int): + await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight_id)) + + order = 0 + 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": ""} + ) + + if item.airport: + airport_id = await handle_combobox_save( + db, models.Airport, item.airport, user_id, + name_column="icao_code", + extra_data={"name": item.airport.name} + ) + + await models.FlightTrack.create( + db, + data={ + "flight_id": flight_id, + "point_of_interest_id": poi_id, + "airport_id": airport_id, + "order": order, + "landing_duration": item.landing_duration + } + ) + order += 1 + + +async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: ComboboxInput): + return await handle_combobox_save( + db, models.Aircraft, aircraft, user_id, + name_column="call_sign", + extra_data={ + "description": "", + "model": "", + "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/__init__.py b/src/graphql_schema/entities/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_schema/entities/types/base.py b/src/graphql_schema/entities/types/base.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_schema/entities/types/flight.py b/src/graphql_schema/entities/types/flight.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_schema/entities/types/mutation_input.py b/src/graphql_schema/entities/types/mutation_input.py new file mode 100644 index 0000000..0d5145b --- /dev/null +++ b/src/graphql_schema/entities/types/mutation_input.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from typing import Optional, List +import strawberry +from strawberry.file_uploads import Upload +from database import models +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input + + +@strawberry.input() +class ComboboxInput: + id: Optional[int] = None + name: str + + +@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" +], all_optional=True) +class EditFlightInput: + gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! + track: Optional[List[TrackItemInput]] = None + copilots: Optional[List[ComboboxInput]] = None + aircraft: Optional[ComboboxInput] = None + landing_airport: Optional[ComboboxInput] = None + takeoff_airport: Optional[ComboboxInput] = None + 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: + aircraft: ComboboxInput + landing_airport: ComboboxInput + takeoff_airport: ComboboxInput + + +@strawberry.input() +class TrackItemInput: + # order: int + point_of_interest: Optional[ComboboxInput] = None + airport: Optional[ComboboxInput] = None + landing_duration: Optional[int] = None diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py new file mode 100644 index 0000000..d665d41 --- /dev/null +++ b/src/graphql_schema/entities/types/types.py @@ -0,0 +1,211 @@ +from __future__ import annotations +from datetime import datetime, timedelta +from typing import Optional, Annotated, List +import strawberry +from sqlalchemy import func, select +from config import API_URL +from database import models +from decorators.endpoints import authenticated_user_only +from dependencies.db import get_session +from external.gpx_parser import GPXParser +from graphql_schema.dataloaders.multi_models import poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_track_dataloader, photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader, aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_dataloader, flights_by_copilot_dataloader +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 + + +@strawberry.type +class Point: + lat: float + 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] + speed: List[float] + altitude: List[float] + magnetic_variation: List[float] + terrain_elevation: List[float] + time: List[datetime] + max_speed: float + avg_speed: float + max_altitude: float + avg_altitude: float + + +@strawberry_sqlalchemy_type(models.Flight) +class Flight: + async def duration_min_calculated(root): + # TODO: predelat na dataloader, dobu nacitat v DB + diff: timedelta = root.landing_datetime - root.takeoff_datetime + total_time_minutes = diff.seconds / 60 + + async with get_session() as db: + landing_durations = (await db.scalars( + select(func.sum(models.FlightTrack.landing_duration)) + .filter(models.FlightTrack.airport_id.isnot(None)) + .filter(models.FlightTrack.flight_id == root.id) + )).one() or 0 + + print(landing_durations) + return total_time_minutes - float(landing_durations) + + async def load_gpx_track(root): + if not root.gpx_track_filename: + return None + + try: + gpx_parser = GPXParser(f"/app/uploads/tracks/{root.gpx_track_filename}") + except OSError: + return None + + return GPXTrack( + coordinates=[Point(**point) for point in await gpx_parser.get_coordinates()], + speed=await gpx_parser.get_speed(), + altitude=await gpx_parser.get_altitude(), + terrain_elevation=await gpx_parser.get_terrain_elevation(), + time=await gpx_parser.get_times(), + max_speed=await gpx_parser.get_max_speed(), + avg_speed=await gpx_parser.get_avg_speed(), + max_altitude=await gpx_parser.get_max_altitude(), + avg_altitude=await gpx_parser.get_avg_altitude(), + magnetic_variation=await gpx_parser.get_magnetic_variation(), + ) + + @authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[]) + async def load_copilots(root): + return await flight_copilots_dataloader.load(root.id) + + @authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[]) + async def load_event(root): + return await event_dataloader.load(root.event_id) + + duration_min_calculated: int = strawberry.field(resolver=duration_min_calculated) + copilots: Optional[List[Copilot]] = strawberry.field(resolver=load_copilots) + event: Optional[Event] = strawberry.field(resolver=load_event) + aircraft: Aircraft = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id)) + takeoff_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.takeoff_airport_id)) + landing_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.landing_airport_id)) + cover_photo: Optional[Photo] = strawberry.field(resolver=lambda root: cover_photo_loader.load(root.id)) + track: List[FlightTrack] = strawberry.field(resolver=lambda root: flight_track_dataloader.load(root.id)) + takeoff_weather_info: Optional[WeatherInfo] = strawberry.field( + resolver=lambda root: airport_weather_info_loader.load(root.takeoff_weather_info_id) + ) + landing_weather_info: Optional[WeatherInfo] = strawberry.field( + resolver=lambda root: airport_weather_info_loader.load(root.landing_weather_info_id) + ) + photos: List[Photo] = strawberry.field(resolver=lambda root: photos_dataloader.load(root.id)) + gpx_track_url: Optional[str] = strawberry.field( + resolver=lambda root: get_public_url(f"/tracks/{root.gpx_track_filename}") if root.gpx_track_filename else None + ) # TODO: odstranit + gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track) + + +@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) + ) + + +@strawberry_sqlalchemy_type(models.Aircraft) +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( + 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( + 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) + organizations: List[Annotated['Organization', strawberry.lazy(".organization")]] = strawberry.field( + resolver=lambda root: user_organizations_dataloader.load(root.id) + ) + + +@strawberry_sqlalchemy_type(models.Event) +class Event: + flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field( + resolver=lambda root: flights_by_event_dataloader.load(root.id) + ) diff --git a/src/graphql_schema/entities/user.py b/src/graphql_schema/entities/user.py index 094fec2..b5b9553 100644 --- a/src/graphql_schema/entities/user.py +++ b/src/graphql_schema/entities/user.py @@ -1,41 +1,18 @@ -from typing import Optional, List, Annotated, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING import strawberry from graphql import GraphQLError from passlib.hash import bcrypt from sqlalchemy import select from strawberry.file_uploads import Upload -from config import API_URL 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.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type -from upload_utils import handle_file_upload, delete_file, get_public_url, resize_image -from ..dataloaders.multi_models import user_organizations_dataloader +from upload_utils import handle_file_upload, delete_file, resize_image +from graphql_schema.entities.types.types import User if TYPE_CHECKING: - from .organization import Organization - - -@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) - organizations: List[Annotated['Organization', strawberry.lazy(".organization")]] = strawberry.field( - resolver=lambda root: user_organizations_dataloader.load(root.id) - ) + pass @strawberry.type diff --git a/src/graphql_schema/mutation.py b/src/graphql_schema/mutation.py index 85ecbc2..9d0ecf2 100644 --- a/src/graphql_schema/mutation.py +++ b/src/graphql_schema/mutation.py @@ -2,7 +2,7 @@ from strawberry.tools import merge_types from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation from graphql_schema.entities.copilot import CreateCopilotMutation, EditCopilotMutation from graphql_schema.entities.event import CreateEventMutation, EditEventMutation -from graphql_schema.entities.flight import CreateFlightMutation, EditFlightMutation, DeleteFlightMutation +from graphql_schema.entities.flight import FlightMutation from graphql_schema.entities.organization import ( CreateOrganizationMutation, EditOrganizationMutation, OrganizationUserMutation ) @@ -14,9 +14,7 @@ Mutation = merge_types("Mutation", ( CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation, - EditFlightMutation, - DeleteFlightMutation, - CreateFlightMutation, + FlightMutation, UploadPhotoMutation, EditPhotoMutation, DeletePhotoMutation, diff --git a/src/graphql_schema/types.py b/src/graphql_schema/types.py deleted file mode 100644 index 3616ca1..0000000 --- a/src/graphql_schema/types.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import Optional -import strawberry - - -@strawberry.input() -class ComboboxInput: - id: Optional[int] = None - name: str diff --git a/src/paths.py b/src/paths.py index 9a29fee..46d984b 100644 --- a/src/paths.py +++ b/src/paths.py @@ -1,3 +1,6 @@ PHOTO_BASE_PATH = "" AIRCRAFT_BASE_PATH = "" FLIGHT_BASE_PATH = "" + +def get_photo_basepath(flight_id: int) -> str: + return f"/app/uploads/photos/{flight_id}"