Stahovani pocasi, editace kopilota, nahravani GPX k letu

This commit is contained in:
Michal Kváček
2023-08-07 23:21:59 +02:00
parent fcd80f20e8
commit ca500472e3
24 changed files with 680 additions and 122 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ async def load(ids: List[int]):
models = (await session.scalars(select(Airport).filter(Airport.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
return [models_by_id[id_] for id_ in ids]
airport_dataloader = DataLoader(load_fn=load, cache=False)
+3 -1
View File
@@ -24,4 +24,6 @@ class FlightsLoader:
return [result_data[id_] for id_ in ids]
flights_by_copilot_dataloader = DataLoader(load_fn=FlightsLoader("copilot_id").load, cache=False)
flights_by_copilot_dataloader = DataLoader(load_fn=FlightsLoader("copilot_id").load, cache=False)
flights_by_aircraft_dataloader = DataLoader(load_fn=FlightsLoader("aircraft_id").load, cache=False)
+16
View File
@@ -0,0 +1,16 @@
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import WeatherInfo
async def load(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(WeatherInfo).filter(WeatherInfo.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
airport_weather_info_loader = DataLoader(load_fn=load, cache=False)
+10 -1
View File
@@ -1,20 +1,29 @@
from typing import List, Optional
from typing import List, Optional, Annotated, TYPE_CHECKING
import strawberry
from strawberry.file_uploads import Upload
from sqlalchemy import select
from database import models
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 ..dataloaders.flight import flights_by_aircraft_dataloader
if TYPE_CHECKING:
from .flight import Flight
AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/"
@strawberry_sqlalchemy_type(models.Aircraft)
class Aircraft:
async def load_flights(root):
return await flights_by_aircraft_dataloader.load(root.id)
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=load_flights)
def get_base_query(user_id: int):
return (
+40 -4
View File
@@ -3,13 +3,14 @@ import strawberry
from sqlalchemy import select
from database import models
from graphql_schema.dataloaders.flight import flights_by_copilot_dataloader
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
if TYPE_CHECKING:
from .flight import Flight
@strawberry_sqlalchemy_type(models.Copilot)
class CopilotType:
class Copilot:
async def load_flights(root):
return await flights_by_copilot_dataloader.load(root.id)
@@ -28,14 +29,49 @@ def get_base_query(user_id: int):
@strawberry.type
class CopilotQueries:
@strawberry.field
async def copilots(root, info) -> List[CopilotType]:
async def copilots(root, info) -> List[Copilot]:
return (await info.context.db.scalars(
get_base_query(info.context.user_id)
)).all()
@strawberry.field
async def copilot(root, info, id: int) -> CopilotType:
async def copilot(root, info, id: int) -> Copilot:
return (await info.context.db.scalars(
get_base_query(info.context.user_id)
.filter(models.Copilot.id == id)
)).one()
@strawberry.type
class CreateCopilotMutation:
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class CreateCopilotInput:
pass
@strawberry.mutation
async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot:
input_data = input.to_dict()
return await models.Copilot.create(
info.context.db,
data=dict(
**input_data,
created_by_id=info.context.user_id,
)
)
@strawberry.type
class EditCopilotMutation:
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class EditCopilotInput:
pass
@strawberry.mutation
async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot:
copilot = (await info.context.db.scalars(
get_base_query(info.context.user_id).filter(models.Copilot.id == id)
)).one()
return await models.Copilot.update(info.context.db, obj=copilot, data=input.to_dict())
+95 -81
View File
@@ -1,8 +1,9 @@
from datetime import timedelta
from typing import List, Optional, Annotated, TYPE_CHECKING
import strawberry
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from strawberry.file_uploads import Upload
from database import models
from graphql_schema.dataloaders import copilots_dataloader
from graphql_schema.dataloaders.aircraft import aircraft_dataloader
@@ -11,26 +12,16 @@ from graphql_schema.dataloaders.photos import photos_dataloader, cover_photo_loa
from graphql_schema.dataloaders.poi import flight_track_dataloader, poi_dataloader
from graphql_schema.entities.aircraft import Aircraft
from graphql_schema.entities.airport import Airport
from graphql_schema.entities.copilot import CopilotType
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, handle_file_upload, check_directories, file_exists, delete_file
from .helpers.flight import handle_aircraft_save, handle_track_edit, handle_copilot_edit, handle_weather_info
from ..dataloaders.weather import airport_weather_info_loader
from ..types import ComboboxInput
if TYPE_CHECKING:
from .copilot import CopilotType
@strawberry.input()
class PointOfInterestInput:
id: Optional[int] = None
name: str
@strawberry.input()
class CopilotInput:
id: Optional[int] = None
name: str
from .copilot import Copilot
@strawberry_sqlalchemy_type(models.FlightTrack)
@@ -41,6 +32,11 @@ class FlightTrack:
point_of_interest: PointOfInterest = strawberry.field(resolver=load_poi)
@strawberry_sqlalchemy_type(models.WeatherInfo)
class WeatherInfo:
pass
@strawberry_sqlalchemy_type(models.Flight)
class Flight:
async def load_takeoff_airport(root):
@@ -64,6 +60,12 @@ class Flight:
async def load_cover_photo(root):
return await cover_photo_loader.load(root.id)
async def load_takeoff_weather_info(root):
return await airport_weather_info_loader.load(root.weather_info_takeoff_id)
async def load_landing_weather_info(root):
return await airport_weather_info_loader.load(root.weather_info_landing_id)
def duration_min_calculated(root):
if root.duration_total:
return root.duration_total
@@ -74,15 +76,23 @@ class Flight:
return 0
def load_gpx_track_url(root):
if not root.gpx_track_filename:
return None
return f"http://localhost:8000/uploads/tracks/{root.gpx_track_filename}"
duration_min_calculated: int = strawberry.field(resolver=duration_min_calculated)
copilot: Optional[Annotated["CopilotType", strawberry.lazy(".copilot")]] = strawberry.field(resolver=load_copilot)
copilot: Optional[Annotated["Copilot", strawberry.lazy(".copilot")]] = strawberry.field(resolver=load_copilot)
aircraft: Aircraft = strawberry.field(resolver=load_aircraft)
takeoff_airport: Airport = strawberry.field(resolver=load_takeoff_airport)
landing_airport: Airport = strawberry.field(resolver=load_landing_airport)
cover_photo: Optional[Photo] = strawberry.field(resolver=load_cover_photo)
track: List[FlightTrack] = strawberry.field(resolver=load_track)
takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_takeoff_weather_info)
landing_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_landing_weather_info)
photos: List[Photo] = strawberry.field(resolver=load_photos)
gpx_track_url: Optional[str] = strawberry.field(resolver=load_gpx_track_url)
def get_base_query(user_id: int):
@@ -96,12 +106,9 @@ def get_base_query(user_id: int):
@strawberry.type
class FlightQueries:
@strawberry.input
class FlightFilters:
takeoff: Optional[int]
@strawberry.field
async def flights(root, info, filters: Optional[FlightFilters] = None) -> List[Flight]:
async def flights(root, info) -> List[Flight]:
query = get_base_query(info.context.user_id).order_by(models.Flight.id.desc())
return (await info.context.db.scalars(query)).all()
@@ -118,88 +125,95 @@ class FlightQueries:
@strawberry.type
class CreateFlightMutation:
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=["id"])
@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"
])
class CreateFlightInput:
pass
aircraft: ComboboxInput
landing_airport: ComboboxInput
takeoff_airport: ComboboxInput
@strawberry.mutation
async def create_flight(self, info, input: CreateFlightInput) -> Flight:
aircraft_id = await handle_aircraft_save(info.context.db, info.context.user_id, input.aircraft)
takeoff_airport = (await info.context.db.scalars(select(models.Airport).filter(models.Airport.id == input.takeoff_airport.id))).one()
if input.takeoff_airport.id == input.landing_airport.id:
landing_airport = takeoff_airport
else:
landing_airport = (await info.context.db.scalars(select(models.Airport).filter(models.Airport.id == input.landing_airport.id))).one()
weather_takeoff = await handle_weather_info(info.context.db, input.takeoff_datetime, takeoff_airport)
weather_landing = await handle_weather_info(info.context.db, input.landing_datetime, landing_airport)
return await models.Flight.create(info.context.db, data={
**input.to_dict(),
"weather_info_takeoff_id": weather_takeoff.id,
"weather_info_landing_id": weather_landing.id,
"takeoff_airport_id": takeoff_airport.id,
"landing_airport_id": landing_airport.id,
"aircraft_id": aircraft_id,
"created_by_id": info.context.user_id
})
async def handle_track_edit(db: AsyncSession, flight: models.Flight, track: List[PointOfInterestInput], 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))
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_copilot_edit(db: AsyncSession, copilot: CopilotInput, user_id: int) -> int:
if copilot.id:
return copilot.id
else:
copilot = await models.Copilot.create(
db,
data={
"name": copilot.name,
"created_by_id": user_id,
}
)
await db.flush()
return copilot.id
@strawberry.type
class EditFlightMutation:
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=["id", "copilot_id", "deleted"], all_optional=True)
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=[
"id", "aircraft_id", "copilot_id", "deleted", "landing_airport_id", "takeoff_airport_id",
"weather_info_takeoff_id", "weather_info_landing_id", "gpx_track_filename"
], all_optional=True)
class EditFlightInput:
track: Optional[List[PointOfInterestInput]] = None
copilot: Optional[CopilotInput] = None
gpx_track: Optional[Upload] = None
track: Optional[List[ComboboxInput]] = None
copilot: Optional[ComboboxInput] = None
aircraft: Optional[ComboboxInput] = None
landing_airport: Optional[ComboboxInput] = None
takeoff_airport: Optional[ComboboxInput] = None
@strawberry.mutation
async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight:
# TODO: umoznit editovat jen vlastni lety!
flight = await models.Flight.update(info.context.db, id=id, data=input.to_dict())
flight = (await info.context.db.scalars(
get_base_query(info.context.user_id).filter(models.Flight.id == id)
)).one()
data = input.to_dict()
if input.gpx_track is not None:
# TODO: poresit validaci uploadovaneho souboru!
path = "/app/uploads/tracks"
if flight.gpx_track_filename and file_exists(path+"/"+flight.gpx_track_filename):
delete_file(path+"/"+flight.gpx_track_filename)
data['gpx_track_filename'] = await handle_file_upload(input.gpx_track, path)
if input.takeoff_airport is not None:
# TODO: stahnout nove pocasi na novem miste! Stejne tak pri zmene data/casu odletu
data['takeoff_airport_id'] = input.takeoff_airport.id
if input.landing_airport is not None:
# TODO: stahnout nove pocasi na novem miste! Stejne tak pri zmene data/casu priletu
data['landing_airport_id'] = input.landing_airport.id
if input.aircraft is not None:
data['aircraft_id'] = await handle_aircraft_save(info.context.db, info.context.user_id, input.aircraft)
flight = await models.Flight.update(info.context.db, id=id, data=data)
if input.track is not None:
await handle_track_edit(db=info.context.db, flight=flight, track=input.track, user_id=info.context.user_id)
if flight.solo:
flight.copilot_id = None
elif input.copilot is not None:
if input.copilot:
flight.copilot_id = await handle_copilot_edit(info.context.db, input.copilot, info.context.user_id)
else:
flight.copilot_id = None
return flight
@strawberry.type
class DeleteFlightMutation:
@@ -214,4 +228,4 @@ class DeleteFlightMutation:
)
flight.deleted = True
return flight
return flight
@@ -0,0 +1,96 @@
from datetime import datetime
from typing import List, Type
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from database import models
from external.weather import Weather
from graphql_schema.types import ComboboxInput
weather_api = Weather()
async def handle_weather_info(db: AsyncSession, date_time: datetime, airport: models.Airport) -> models.WeatherInfo:
weather = await weather_api.get_weather_for_hour(date_time, (airport.gps_latitude, airport.gps_longitude))
return await models.WeatherInfo.create(db_session=db, 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 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))
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):
if aircraft.id:
return aircraft.id
else:
obj = await models.Aircraft.create(db, {
"call_sign": aircraft.name,
"description": "",
"model": "",
"manufacturer": "",
"created_by_id": user_id
})
await db.flush()
return obj.id
async def handle_copilot_edit(db: AsyncSession, copilot: ComboboxInput, user_id: int) -> int:
return await handle_combobox_save(db, models.Copilot, copilot, user_id)
async def handle_combobox_save(
db: AsyncSession, model: Type[models.BaseModel],
input: ComboboxInput,
user_id: int,
name_column: str = "name"
):
if input.id:
return input.id
else:
data = {name_column: input.name}
if hasattr(model, "created_by_id"):
data["created_by_id"] = user_id
obj = await model.create(db, data)
await db.flush()
return obj.id
+25 -14
View File
@@ -4,17 +4,23 @@ from sqlalchemy import select, update
from strawberry.file_uploads import Upload
from database import models
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
from upload_utils import get_public_url, handle_file_upload, delete_file
from upload_utils import get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists
@strawberry_sqlalchemy_type(models.Photo)
class Photo:
url: str = strawberry.field(
resolver=lambda root: get_public_url(f"photos/{root.flight_id}/{root.filename}")
)
thumbnail_url: str = strawberry.field(
resolver=lambda root: get_public_url(f"photos/{root.flight_id}/{root.filename}") # TODO: doplnit thumb!
)
def resolve_url(root):
return get_public_url(f"photos/{root.flight_id}/{root.filename}")
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=resolve_url)
thumbnail_url: str = strawberry.field(resolver=resolve_thumb_url)
def get_base_query(user_id: int):
@@ -39,28 +45,33 @@ class PhotoQueries:
@strawberry.type
class UploadPhotoMutation:
@strawberry_sqlalchemy_input(models.Photo, exclude_fields=["id", "filename", "is_flight_cover"])
@strawberry_sqlalchemy_input(models.Photo, exclude_fields=[
"id", "filename", "is_flight_cover", "exposed_at", "gps_latitude", "gps_longitude", "gps_altitude"
])
class UploadPhotoInput:
photo: Upload
@strawberry.mutation
async def upload_photo(self, info, input: UploadPhotoInput) -> Photo:
filename = await handle_file_upload(input.photo, get_photo_basepath(input.flight_id))
path = get_photo_basepath(input.flight_id)
filename = await handle_file_upload(input.photo, path)
info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename, size=(300, 200))
# todo: udelat nahled do thumbs slozky
exif_info = await parse_exif_info(path, filename)
is_flight_cover = False # TODO: pokud k letu neexistuje zadna fotka, vybrat nahodne jednu a tu nastavit jako cover
created_photo = await models.Photo.create(data={
"flight_id": input.flight_id,
"name": input.name,
"filename": filename,
"description": input.description,
"is_flight_cover": is_flight_cover,
"exposed_at": exif_info.get("datetime"),
"gps_latitude": exif_info.get("gps_latitude"),
"gps_longitude": exif_info.get("gps_longitude"),
"gps_altitude": exif_info.get("gps_altitude"),
"is_flight_cover": False,
"created_by_id": info.context.user_id,
}, db_session=info.context.db)
await info.context.db.flush()
return created_photo
+3
View File
@@ -1,5 +1,6 @@
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.flight import CreateFlightMutation, EditFlightMutation, DeleteFlightMutation
from graphql_schema.entities.photo import UploadPhotoMutation, DeletePhotoMutation, EditPhotoMutation
from graphql_schema.entities.poi import CreatePointOfInterestMutation, EditPointOfInterestMutation
@@ -16,4 +17,6 @@ Mutation = merge_types("Mutation", (
DeletePhotoMutation,
CreatePointOfInterestMutation,
EditPointOfInterestMutation,
CreateCopilotMutation,
EditCopilotMutation,
))
+3 -1
View File
@@ -1,8 +1,9 @@
import dataclasses
import strawberry
from fastapi_jwt import JwtAuthorizationCredentials
from fastapi_jwt.jwt import JwtAccessBearer, JwtAccessBearerCookie
from fastapi_jwt.jwt import JwtAccessBearerCookie
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTasks
from strawberry.extensions import SchemaExtension
from strawberry.fastapi import BaseContext
from .mutation import Mutation
@@ -33,6 +34,7 @@ class GraphQLContext(BaseContext):
user_id: int
jwt_auth_credentials: JwtAuthorizationCredentials
jwt: JwtAccessBearerCookie
background_tasks: BackgroundTasks
schema = strawberry.Schema(
+8
View File
@@ -0,0 +1,8 @@
from typing import Optional
import strawberry
@strawberry.input()
class ComboboxInput:
id: Optional[int] = None
name: str