Stahovani pocasi, editace kopilota, nahravani GPX k letu
This commit is contained in:
+29
-12
@@ -27,8 +27,7 @@ class BaseModel:
|
||||
async def create(cls, db_session: AsyncSession, data: dict):
|
||||
model = cls(**data)
|
||||
db_session.add(model)
|
||||
# await db_session.flush()
|
||||
# await db_session.refresh(model)
|
||||
await db_session.flush()
|
||||
|
||||
return model
|
||||
|
||||
@@ -43,12 +42,10 @@ class BaseModel:
|
||||
if getattr(obj, key) != value:
|
||||
setattr(obj, key, value)
|
||||
|
||||
# await db_session.commit()
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
# TODO: doplnit GPX k letu, pocasi k letu (podle lokality, mozna do FlightTrack)
|
||||
# TODO: doplnit GPX k letu
|
||||
|
||||
user_is_in_organization = Table(
|
||||
"user_is_in_organization",
|
||||
@@ -67,10 +64,13 @@ class Airport(BaseModel):
|
||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
elevation: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=True) # automaticky import nebude mit ID
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
|
||||
|
||||
metars: Mapped['Metar'] = relationship(back_populates="airport")
|
||||
created_by: Mapped['User'] = relationship()
|
||||
|
||||
|
||||
class PointOfInterestType(BaseModel):
|
||||
@@ -112,8 +112,10 @@ class Photo(BaseModel):
|
||||
filename: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0")
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
gps_longitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
gps_altitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True)
|
||||
flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False)
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
@@ -184,23 +186,38 @@ class FlightTrack(BaseModel):
|
||||
point_of_interest: Mapped['PointOfInterest'] = relationship()
|
||||
|
||||
|
||||
class WeatherInfo(BaseModel):
|
||||
__tablename__ = "weather_info"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
qnh: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
temperature_surface: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
dewpoint_surface: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
rain: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
cloudcover_low: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
cloudcover_total: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
wind_speed_surface: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
wind_direction_surface: Mapped[Float] = mapped_column(Float, nullable=True)
|
||||
datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
|
||||
class Flight(BaseModel):
|
||||
__tablename__ = "flight"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False)
|
||||
landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False)
|
||||
takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
landing_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
duration_total: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
duration_pic: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
|
||||
landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
|
||||
gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey('aircraft.id'))
|
||||
copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True)
|
||||
|
||||
solo: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
with_instructor: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
weather_info_takeoff_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
|
||||
weather_info_landing_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
|
||||
landings: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
@@ -209,6 +226,8 @@ class Flight(BaseModel):
|
||||
|
||||
takeoff_airport: Mapped['Airport'] = relationship(foreign_keys=[takeoff_airport_id])
|
||||
landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id])
|
||||
weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[weather_info_landing_id])
|
||||
weather_info_takeoff: Mapped[WeatherInfo] = relationship(foreign_keys=[weather_info_takeoff_id])
|
||||
track: Mapped['FlightTrack'] = relationship()
|
||||
copilot: Mapped['Copilot'] = relationship(back_populates="flights")
|
||||
aircraft: Mapped['Aircraft'] = relationship(back_populates="flights")
|
||||
@@ -216,8 +235,6 @@ class Flight(BaseModel):
|
||||
user: Mapped['User'] = relationship(back_populates="flights")
|
||||
created_by: Mapped['User'] = relationship()
|
||||
|
||||
# flight_track: Mapped[List['PointOfInterest']] = relationship(secondary=FlightTrack)
|
||||
|
||||
|
||||
class Copilot(BaseModel):
|
||||
__tablename__ = "copilot"
|
||||
|
||||
@@ -48,6 +48,7 @@ class LoginEndpoint(BaseEndpoint):
|
||||
"access_token_validity": self.access_security.access_expires_delta.total_seconds(),
|
||||
}
|
||||
|
||||
|
||||
class RefreshEndpoint(BaseEndpoint):
|
||||
def __init__(self, access_token: JwtAccess, refresh_token: JwtRefresh):
|
||||
super().__init__(db=None)
|
||||
|
||||
Vendored
+58
@@ -0,0 +1,58 @@
|
||||
import datetime
|
||||
import urllib.parse
|
||||
from typing import Tuple, Dict
|
||||
import aiohttp
|
||||
from aiocache import cached
|
||||
|
||||
|
||||
class Weather:
|
||||
FORECAST_URL = "https://api.open-meteo.com/v1/forecast?"
|
||||
ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive?"
|
||||
TIMEZONE = "Europe/Prague"
|
||||
METRICS = (
|
||||
"pressure_msl", "temperature_2m", "dewpoint_2m", "rain", "cloudcover_low", "cloudcover", "windspeed_10m",
|
||||
"winddirection_10m"
|
||||
)
|
||||
|
||||
def get_weather_info_url(self, start_date: datetime.date, end_date: datetime.date, gps: Tuple[float, float]) -> str:
|
||||
today = datetime.datetime.now().date()
|
||||
date_diff = today - end_date
|
||||
|
||||
if date_diff.days >= 7:
|
||||
# historical API offers data only older than 5 days
|
||||
url = self.ARCHIVE_URL
|
||||
else:
|
||||
# forecast contains data even 14 days ago
|
||||
url = self.FORECAST_URL
|
||||
|
||||
params = {
|
||||
"latitude": gps[0],
|
||||
"longitude": gps[1],
|
||||
"timezone": self.TIMEZONE,
|
||||
"hourly": ','.join(self.METRICS),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
}
|
||||
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
return f"{url}{query_string}"
|
||||
|
||||
@cached(ttl=6*3600)
|
||||
async def download_weather_for_day(self, date: datetime.date, gps: Tuple[float, float]):
|
||||
url = self.get_weather_info_url(start_date=date, end_date=date, gps=gps)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def get_weather_for_hour(self, date_time: datetime.datetime, gps: Tuple[float, float]) -> Dict[str, float|str]:
|
||||
data = await self.download_weather_for_day(date_time.date(), gps)
|
||||
|
||||
# TODO: kontrola timezone!
|
||||
# TODO: interpolace - udelat vazenyprumer z dvou po sobe jdoucich hodin
|
||||
idx = date_time.hour
|
||||
result_data = {metric: data['hourly'][metric][idx] for metric in self.METRICS}
|
||||
result_data['datetime'] = datetime.datetime.strptime(data['hourly']['time'][idx], "%Y-%m-%dT%H:%M")
|
||||
|
||||
return result_data
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 (
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
|
||||
|
||||
@strawberry.input()
|
||||
class ComboboxInput:
|
||||
id: Optional[int] = None
|
||||
name: str
|
||||
+11
-6
@@ -1,9 +1,11 @@
|
||||
from datetime import timedelta
|
||||
from fastapi import FastAPI, APIRouter, Depends, Security, HTTPException
|
||||
from fastapi_jwt import JwtAuthorizationCredentials, JwtRefreshBearer, JwtAccessBearerCookie, JwtRefreshBearerCookie
|
||||
from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTasks
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
from starlette.staticfiles import StaticFiles
|
||||
from starlette.status import HTTP_401_UNAUTHORIZED
|
||||
from strawberry.fastapi import GraphQLRouter
|
||||
from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG
|
||||
@@ -19,7 +21,7 @@ class App:
|
||||
access_security = JwtAccessBearerCookie(
|
||||
secret_key=APP_SECRET_KEY,
|
||||
auto_error=False,
|
||||
access_expires_delta=timedelta(seconds=30)
|
||||
access_expires_delta=timedelta(minutes=20)
|
||||
)
|
||||
refresh_security = JwtRefreshBearerCookie(
|
||||
secret_key=APP_SECRET_KEY,
|
||||
@@ -41,6 +43,7 @@ class App:
|
||||
|
||||
@staticmethod
|
||||
def setup_middleware(app: FastAPI):
|
||||
print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:9001"],
|
||||
@@ -62,7 +65,7 @@ class App:
|
||||
|
||||
def setup_graphql_context(
|
||||
credentials: JwtAuthorizationCredentials = Security(self.access_security),
|
||||
db: AsyncSession = Depends(db_session),
|
||||
db: AsyncSession = Depends(db_session)
|
||||
):
|
||||
if not credentials:
|
||||
raise HTTPException(status_code=HTTP_401_UNAUTHORIZED)
|
||||
@@ -71,7 +74,8 @@ class App:
|
||||
jwt_auth_credentials=credentials,
|
||||
user_id=credentials['id'],
|
||||
db=db,
|
||||
jwt=self.access_security
|
||||
jwt=self.access_security,
|
||||
background_tasks=Depends(BackgroundTasks)
|
||||
)
|
||||
|
||||
graphql_app = GraphQLRouter(
|
||||
@@ -96,11 +100,12 @@ class App:
|
||||
resp: Response,
|
||||
credentials: JwtAuthorizationCredentials = Security(self.refresh_security)
|
||||
):
|
||||
return await RefreshEndpoint(self.access_security, self.refresh_security).on_post(resp, credentials)
|
||||
|
||||
return await RefreshEndpoint(self.access_security, self.refresh_security).on_post(resp, credentials)
|
||||
|
||||
self.setup_graphql_endpoint(app)
|
||||
|
||||
app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads")
|
||||
|
||||
# public endpoints
|
||||
|
||||
@self.api_router.post("/login")
|
||||
|
||||
+46
-1
@@ -1,10 +1,14 @@
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple
|
||||
import exif
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from strawberry.file_uploads import Upload
|
||||
|
||||
|
||||
def get_public_url(filename: Optional[str]):
|
||||
# TODO: pouzit staticfiles z /uploads - port na API, nginx nebude potreba (pro dev)
|
||||
return f"http://localhost:8999/{filename}" if filename else None
|
||||
|
||||
|
||||
@@ -13,6 +17,10 @@ def check_directories(path: str):
|
||||
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)
|
||||
|
||||
@@ -26,5 +34,42 @@ async def handle_file_upload(file: Upload, path: str):
|
||||
return filename
|
||||
|
||||
|
||||
def gps_to_decimal(input: Tuple[float, float, float]) -> float:
|
||||
d, m, s = input
|
||||
return d + (m / 60.0) + (s / 3600.0)
|
||||
|
||||
|
||||
async def parse_exif_info(path: str, filename: str) -> dict:
|
||||
with open(f"{path}/{filename}", "rb") as f:
|
||||
img = exif.Image(f)
|
||||
if not img.has_exif:
|
||||
return {}
|
||||
|
||||
exif_info = img.get_all()
|
||||
|
||||
for datetime_field in ("datetime", "datetime_original", "datetime_digitized"):
|
||||
if exif_info.get(datetime_field):
|
||||
print(exif_info[datetime_field])
|
||||
exif_info[datetime_field] = datetime.strptime(exif_info[datetime_field], "%Y:%m:%d %H:%M:%S")
|
||||
|
||||
if exif_info.get("gps_latitude"):
|
||||
exif_info["gps_latitude"] = gps_to_decimal(exif_info["gps_latitude"])
|
||||
|
||||
if exif_info.get("gps_longitude"):
|
||||
exif_info["gps_longitude"] = gps_to_decimal(exif_info["gps_longitude"])
|
||||
|
||||
return exif_info
|
||||
|
||||
|
||||
async def generate_thumbnail(path: str, filename: str, size: Tuple[int, int]):
|
||||
try:
|
||||
image = Image.open(f"{path}/{filename}")
|
||||
image.thumbnail(size)
|
||||
check_directories(f"{path}/thumbs/")
|
||||
image.save(f"{path}/thumbs/{filename}")
|
||||
except UnidentifiedImageError:
|
||||
pass
|
||||
|
||||
|
||||
def delete_file(path: str, silent: bool = False):
|
||||
os.remove(path)
|
||||
|
||||
Reference in New Issue
Block a user