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
@@ -0,0 +1,40 @@
"""gps u letiste nejsou nullable, pridani sloupce photo.exposed_at
Revision ID: 708fc32f6a3b
Revises: 804e55bbf855
Create Date: 2023-07-31 04:51:26.257834
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '708fc32f6a3b'
down_revision = '804e55bbf855'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('airport', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=False)
op.alter_column('airport', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=False)
op.add_column('photo', sa.Column('exposed_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('photo', 'exposed_at')
op.alter_column('airport', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('airport', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=True)
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""photo.gps_altitude
Revision ID: cf1fb904e9ab
Revises: 708fc32f6a3b
Create Date: 2023-07-31 08:57:10.715298
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cf1fb904e9ab'
down_revision = '708fc32f6a3b'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('photo', sa.Column('gps_altitude', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('photo', 'gps_altitude')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""add weather info
Revision ID: 36ea098fde31
Revises: cf1fb904e9ab
Create Date: 2023-08-01 04:37:17.188376
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '36ea098fde31'
down_revision = 'cf1fb904e9ab'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airport', sa.Column('created_by_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'airport', 'user', ['created_by_id'], ['id'])
op.add_column('flight', sa.Column('weather_info_takeoff_id', sa.Integer(), nullable=True))
op.add_column('flight', sa.Column('weather_info_landing_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'weather_info', ['weather_info_takeoff_id'], ['id'])
op.create_foreign_key(None, 'flight', 'weather_info', ['weather_info_landing_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('flight_ibfk_6', 'flight', type_='foreignkey')
op.drop_constraint('flight_ibfk_7', 'flight', type_='foreignkey')
op.drop_column('flight', 'weather_info_landing_id')
op.drop_column('flight', 'weather_info_takeoff_id')
op.drop_constraint('airport_ibfk_1', 'airport', type_='foreignkey')
op.drop_column('airport', 'created_by_id')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add datetime to weather_info
Revision ID: 2a320e1e093d
Revises: 36ea098fde31
Create Date: 2023-08-01 04:47:22.122423
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2a320e1e093d'
down_revision = '36ea098fde31'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('weather_info', sa.Column('datetime', sa.DateTime(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('weather_info', 'datetime')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add gpx to flight
Revision ID: 1e04cd7838dd
Revises: 2a320e1e093d
Create Date: 2023-08-07 18:55:00.914684
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1e04cd7838dd'
down_revision = '2a320e1e093d'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight', sa.Column('gpx_track_filename', sa.String(length=128), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('flight', 'gpx_track_filename')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""airports cannot be nullable
Revision ID: f85556b2ce2c
Revises: 1e04cd7838dd
Create Date: 2023-08-07 19:14:17.791435
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f85556b2ce2c'
down_revision = '1e04cd7838dd'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('flight', 'takeoff_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('flight', 'landing_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('flight', 'landing_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('flight', 'takeoff_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""remove not used fields in flight
Revision ID: b5c66e5a2df7
Revises: f85556b2ce2c
Create Date: 2023-08-07 20:53:00.717813
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'b5c66e5a2df7'
down_revision = 'f85556b2ce2c'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('flight', 'with_instructor')
op.drop_column('flight', 'solo')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight', sa.Column('solo', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
op.add_column('flight', sa.Column('with_instructor', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False))
# ### end Alembic commands ###
+5
View File
@@ -8,3 +8,8 @@ alembic==1.11.1
passlib==1.7.4
pydantic==1.10.11 # vysla uz 2.0, ale nejak mi to nefunguje
pillow
exif
aiocache
aiohttp
+29 -12
View File
@@ -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"
+1
View File
@@ -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)
+58
View File
@@ -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
+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
+11 -6
View File
@@ -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
View File
@@ -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)