53 changed files with 1385 additions and 7316 deletions
+2
View File
@@ -10,3 +10,5 @@ uploads/*
!uploads/.gitkeep
src/emails/templates/html/*
.env
/static/airports/
/static/airports/.gitkeep
+7 -5
View File
@@ -9,15 +9,17 @@ RUN mjml /app/templates/* -o /app/templates/html/
FROM python:3.11-alpine AS app
# protoze xfs uzivatel ma id=33
# RUN deluser xfs
# uzivatel www-data v alpine neexistuje
RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data
RUN apk update && \
apk add --no-cache tzdata mariadb-client mariadb-dev && \
pip install --upgrade pip
# protoze xfs uzivatel ma id=33
RUN deluser xfs
# uzivatel www-data v alpine neexistuje
RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data
COPY requirements.txt /app/requirements.txt
COPY --from=email_builder /app/templates/html/ /app/src/email/templates/html/
@@ -0,0 +1,30 @@
"""add weather info to flight plan markers
Revision ID: 8a67af3909aa
Revises: 790ba00fc021
Create Date: 2024-05-16 06:19:13.112911
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a67af3909aa'
down_revision = '790ba00fc021'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan_marker', sa.Column('weather_info_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan_marker', 'weather_info', ['weather_info_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_column('flight_plan_marker', 'weather_info_id')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add flight_plan.description
Revision ID: a3e1f1a37899
Revises: 8a67af3909aa
Create Date: 2024-06-11 16:46:29.786830
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a3e1f1a37899'
down_revision = '8a67af3909aa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan', sa.Column('description', sa.Text(), server_default='', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('flight_plan', 'description')
# ### end Alembic commands ###
@@ -0,0 +1,54 @@
"""add track to flight
Revision ID: f9e1e8a01683
Revises: a3e1f1a37899
Create Date: 2024-06-24 16:08:01.084570
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f9e1e8a01683'
down_revision = 'a3e1f1a37899'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('track',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('bounds', mysql.JSON(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('track_point',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('track_id', sa.Integer(), nullable=False),
sa.Column('gps_latitude', sa.Float(), nullable=False),
sa.Column('gps_longitude', sa.Float(), nullable=False),
sa.Column('elevation', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['track_id'], ['track.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('flight', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'track', ['track_id'], ['id'])
op.add_column('flight_plan', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan', 'track', ['track_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan', type_='foreignkey')
op.drop_column('flight_plan', 'track_id')
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'track_id')
op.drop_table('track_point')
op.drop_table('track')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""add flight.flight_plan_id fk
Revision ID: 989152b7184a
Revises: f9e1e8a01683
Create Date: 2024-06-27 17:09:19.933094
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '989152b7184a'
down_revision = 'f9e1e8a01683'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight', sa.Column('flight_plan_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'flight_plan', ['flight_plan_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'flight_plan_id')
# ### end Alembic commands ###
@@ -0,0 +1,40 @@
"""Add poi and airport id to flight plan track marker
Revision ID: 8cc01c03e980
Revises: 989152b7184a
Create Date: 2024-06-30 00:27:08.976891
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '8cc01c03e980'
down_revision = '989152b7184a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('airport', 'openaip_id',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.add_column('flight_plan_marker', sa.Column('airport_id', sa.Integer(), nullable=True))
op.add_column('flight_plan_marker', sa.Column('point_of_interest_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan_marker', 'point_of_interest', ['point_of_interest_id'], ['id'])
op.create_foreign_key(None, 'flight_plan_marker', 'airport', ['airport_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_column('flight_plan_marker', 'point_of_interest_id')
op.drop_column('flight_plan_marker', 'airport_id')
op.alter_column('airport', 'openaip_id',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,46 @@
"""add speed, elevation, altitude to track
Revision ID: 03de3f7f8fdd
Revises: 8cc01c03e980
Create Date: 2024-07-11 07:45:59.376941
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03de3f7f8fdd'
down_revision = '8cc01c03e980'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('track', sa.Column('min_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('max_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('avg_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('max_altitude', sa.Float(), nullable=True))
op.add_column('track', sa.Column('avg_altitude', sa.Float(), nullable=True))
op.add_column('track', sa.Column('total_duration', sa.Integer(), nullable=True, comment='Total duration in seconds'))
op.add_column('track_point', sa.Column('terrain_elevation', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('speed', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('altitude', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('magnetic_variation', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('track_point', 'magnetic_variation')
op.drop_column('track_point', 'altitude')
op.drop_column('track_point', 'speed')
op.drop_column('track_point', 'terrain_elevation')
op.drop_column('track', 'total_duration')
op.drop_column('track', 'avg_altitude')
op.drop_column('track', 'max_altitude')
op.drop_column('track', 'avg_speed')
op.drop_column('track', 'max_speed')
op.drop_column('track', 'min_speed')
# ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""delete duplicated column with altitude
Revision ID: e3620deb41b6
Revises: 03de3f7f8fdd
Create Date: 2024-07-15 06:35:13.536320
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'e3620deb41b6'
down_revision = '03de3f7f8fdd'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.rename_table("flight_track", "flight_turn_point")
op.create_index(op.f('ix_track_point_timestamp'), 'track_point', ['timestamp'], unique=False)
op.drop_column('track_point', 'elevation')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('track_point', sa.Column('elevation', mysql.FLOAT(), nullable=True))
op.drop_index(op.f('ix_track_point_timestamp'), table_name='track_point')
op.rename_table("flight_turn_point", "flight_track")
# ### end Alembic commands ###
+1
View File
@@ -12,3 +12,4 @@ services:
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
volumes:
- ./uploads:/app/uploads
- ./static:/app/static
+14 -14
View File
@@ -26,20 +26,20 @@ services:
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000"
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
adminer:
image: adminer
restart: always
ports:
- 8080:8080
scrapy:
build:
context: ../crawlers
env_file:
- .env
volumes:
- db-data:/var/lib/mysql:rw
- ../crawlers:/app
# adminer:
# image: adminer
# restart: always
# ports:
# - 8080:8080
#
# scrapy:
# build:
# context: ../crawlers
# env_file:
# - .env
# volumes:
# - db-data:/var/lib/mysql:rw
# - ../crawlers:/app
volumes:
db-data: { }
+1
View File
@@ -14,3 +14,4 @@ exif==1.6.0
aiocache==0.12.2
aiohttp==3.9.3
lxml==5.1.0
geopy==2.4.1
+18 -24
View File
@@ -1,40 +1,34 @@
from aiohttp import ClientResponseError
from sqlalchemy import select
from database import models
from database.transaction import get_session
from external.elevation import elevation_api
from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH
from decorators.retryable import retryable
from external.elevation import elevation_api, update_track_points_elevation
async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
coordinates = await gpx_parser.get_coordinates_all()
try:
elevation = await elevation_api.get_elevation_for_points(coordinates)
tree_with_elevation = gpx_parser.add_terrain_elevation(elevation)
output_name = f"terrain_{gpx_filename}"
gpx_parser.write(tree_with_elevation, f"{FLIGHT_GPX_TRACK_PATH}/{output_name}")
@retryable
async def add_terrain_elevation_to_flight(flight_id: int):
async with get_session() as db:
await models.Flight.update(
db, {"gpx_track_filename": output_name, "has_terrain_elevation": True},
id=flight_id
)
except ClientResponseError as e:
print(e)
track_points = (
await db.scalars(
select(models.TrackPoint)
.join(models.TrackPoint.track)
.join(models.Track.flight)
.filter(models.Flight.id == flight_id))
).all()
await update_track_points_elevation(db, track_points)
@retryable
async def add_terrain_elevation_to_photo(photo):
try:
elevation = await elevation_api.get_elevation_for_points([
{"lat": photo.gps_latitude, "lng": photo.gps_longitude}
])
elevation = await elevation_api.get_elevation_for_points(
[{"lat": photo.gps_latitude, "lng": photo.gps_longitude}]
)
if not elevation:
print("Cannot get elevation")
return
terrain_elevation = elevation[0]['elevation']
terrain_elevation = elevation[0].elevation
async with get_session() as db:
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
except Exception as e:
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime, timedelta
from sqlalchemy import select, delete
from database import models
from database.transaction import get_session
from utils.gps import generate_points
async def generate_track_for_flight_plan(flight_plan_id: int):
async with (get_session() as db):
flight_plan = await models.FlightPlan.get_one(db, id=flight_plan_id)
turn_points = (
await db.scalars(
select(models.FlightPlanMarker)
.filter(models.FlightPlanMarker.flight_plan_id == flight_plan_id)
)).all()
if len(turn_points) == 1:
return
if flight_plan.track_id:
track = await models.Track.get_one(db, id=flight_plan.track_id)
await db.execute(
delete(models.TrackPoint)
.filter(models.TrackPoint.track_id == track.id)
)
else:
track = await models.Track.create(db, {"bounds": [], "created_by_id": flight_plan.created_by_id})
flight_plan.track_id = track.id
point_ts = flight_plan.planned_takeoff_datetime or datetime.now()
track_points_resolution_meters = 500
speed_m_s = flight_plan.planned_speed / 3.6
delta_t_sec = track_points_resolution_meters / speed_m_s
for i in range(len(turn_points) - 1):
from_ = turn_points[i]
to = turn_points[i + 1]
point_from = (from_.gps_latitude, from_.gps_longitude)
point_to = (to.gps_latitude, to.gps_longitude)
points_between = generate_points(point_from, point_to)
for point in points_between:
await models.TrackPoint.create(db, {
"track_id": track.id,
"timestamp": point_ts,
"gps_latitude": point.latitude,
"gps_longitude": point.longitude,
})
point_ts += timedelta(seconds=delta_t_sec)
+58
View File
@@ -0,0 +1,58 @@
from PIL import Image, ImageDraw, ImageOps
async def add_circular_avatar(base_image_path, avatar_image_path, output_image_path):
# Load the base image
base_image = Image.open(base_image_path)
base_width, base_height = base_image.size
# Calculate the new dimensions for cropping to a 1.9:1 aspect ratio
new_width = base_width
new_height = int(new_width / 1.9)
if new_height > base_height:
new_height = base_height
new_width = int(new_height * 1.9)
# Center crop the base image to the new dimensions
left = (base_width - new_width) // 2
top = (base_height - new_height) // 2
right = (base_width + new_width) // 2
bottom = (base_height + new_height) // 2
base_image = base_image.crop((left, top, right, bottom))
base_width, base_height = base_image.size
# Load the avatar image
avatar_image = Image.open(avatar_image_path)
avatar_size = int(base_height * 0.3) # Set the size of the avatar
avatar_image = avatar_image.resize((avatar_size, avatar_size), Image.LANCZOS)
# Create a mask to make the avatar circular
mask = Image.new('L', (avatar_size, avatar_size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0, avatar_size, avatar_size), fill=255)
# Apply the mask to the avatar image
avatar_image = ImageOps.fit(avatar_image, mask.size, centering=(0.5, 0.5))
avatar_image.putalpha(mask)
# Create a border around the avatar
border_size = int(avatar_size * 0.03)
border_image = Image.new('RGBA', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), (255, 255, 255, 0))
border_mask = Image.new('L', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), 0)
border_draw = ImageDraw.Draw(border_mask)
border_draw.ellipse((0, 0, avatar_size + 2 * border_size, avatar_size + 2 * border_size), fill=255)
border_draw.ellipse((border_size, border_size, avatar_size + border_size, avatar_size + border_size), fill=0)
avatar_margin = int(avatar_size * 0.1)
border_image.putalpha(border_mask)
border_image.paste(avatar_image, (border_size, border_size), avatar_image)
# Calculate the position to paste the avatar (bottom right corner)
position = (base_width - avatar_size - 2 * border_size - avatar_margin, base_height - avatar_size - 2 * border_size - avatar_margin)
# Paste the avatar with border onto the base image
base_image.paste(border_image, position, border_image)
# Save the result
base_image.save(output_image_path)
+27 -7
View File
@@ -6,19 +6,14 @@ from external.weather import weather_api
from logger import log
async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
async with get_session() as db:
airport = await models.Airport.get_one(db, id=airport_id)
gps = (airport.gps_latitude, airport.gps_longitude)
async def get_weather(date_time: datetime, gps: tuple[float, float]):
try:
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps)
log.warning(weather)
except Exception as e:
log.error(f"Error in downloading weather: {e}")
return None
data = {
return {
"datetime": weather['datetime'],
"qnh": weather['pressure_msl'],
"temperature_surface": weather['temperature_2m'],
@@ -30,6 +25,14 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int,
"wind_direction_surface": weather['winddirection_10m'],
}
async def download_weather_for_flight(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
async with get_session() as db:
airport = await models.Airport.get_one(db, id=airport_id)
gps = (airport.gps_latitude, airport.gps_longitude)
data = await get_weather(date_time, gps)
async with get_session() as db:
flight = await models.Flight.get_one(db, id=flight_id)
existing_weather_id = getattr(flight, f'{type_}_weather_info_id')
@@ -39,3 +42,20 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int,
else:
weather_model = await models.WeatherInfo.create(db, data=data)
await models.Flight.update(db, obj=flight, data={f"{type_}_weather_info_id": weather_model.id})
async def download_weather_for_plan(date_time: datetime, flight_plan_id: int, gps: tuple[float, float]):
data = await get_weather(date_time, gps)
async with get_session() as db:
plan = await models.FlightPlan.get_one(db, id=flight_plan_id)
if plan.weather_info_id:
await models.WeatherInfo.update(db, id=plan.weather_info.id, data=data)
else:
weather_model = await models.WeatherInfo.create(db, data=data)
await models.FlightPlan.update(
db,
obj=plan,
data={"weather_info_id": weather_model.id}
)
+1 -1
View File
@@ -1,6 +1,6 @@
import os
APP_DEBUG = True
APP_DEBUG = os.environ.get("APP_ENV", 'production') == 'development'
GRAPHIQL = True
REFRESH_TOKEN_VALIDITY_DAYS = 30
+53 -26
View File
@@ -74,7 +74,6 @@ copilot_has_photo = Table(
Column("photo_id", ForeignKey("photo.id"), primary_key=True),
)
airport_has_reporting_point = Table(
"airport_has_reporting_point",
BaseModel.metadata,
@@ -105,6 +104,8 @@ class FlightPlan(BaseModel):
is_default_name: Mapped[bool] = mapped_column(Boolean, server_default='0')
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
is_editable_by_copilot: Mapped[bool] = mapped_column(Boolean, server_default='0')
track_id: Mapped[int] = mapped_column(Integer, ForeignKey('track.id'), nullable=True)
description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
planned_speed: Mapped[int] = mapped_column(Integer, nullable=False, default=150)
planned_takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=True)
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=True)
@@ -114,15 +115,53 @@ class FlightPlan(BaseModel):
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_plan_has_copilot)
aircraft: Mapped['Aircraft'] = relationship()
markers: Mapped['FlightPlanMarker'] = relationship()
markers: Mapped[List['FlightPlanMarker']] = relationship()
created_by: Mapped['User'] = relationship()
class Track(BaseModel):
__tablename__ = "track"
id: Mapped[int] = mapped_column(primary_key=True)
bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON())
min_speed: Mapped[float] = mapped_column(Float, nullable=True)
max_speed: Mapped[float] = mapped_column(Float, nullable=True)
avg_speed: Mapped[float] = mapped_column(Float, nullable=True)
max_altitude: Mapped[float] = mapped_column(Float, nullable=True)
avg_altitude: Mapped[float] = mapped_column(Float, nullable=True)
total_duration: Mapped[int] = mapped_column(Integer, nullable=True, comment="Total duration in seconds")
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
flight: Mapped['Flight'] = relationship()
flight_plan: Mapped['FlightPlan'] = relationship()
# created_by: Mapped['User'] = relationship()
class TrackPoint(BaseModel):
__tablename__ = "track_point"
id: Mapped[int] = mapped_column(primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
track_id: Mapped[id] = mapped_column(Integer, ForeignKey('track.id'))
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
terrain_elevation: Mapped[float] = mapped_column(Float, nullable=True)
speed: Mapped[float] = mapped_column(Float, nullable=True)
altitude: Mapped[float] = mapped_column(Float, nullable=True)
magnetic_variation: Mapped[float] = mapped_column(Float, nullable=True)
track: Mapped['Track'] = relationship()
class FlightPlanMarker(BaseModel):
__tablename__ = "flight_plan_marker"
id: Mapped[int] = mapped_column(primary_key=True)
flight_plan_id: Mapped[int] = mapped_column(Integer, ForeignKey('flight_plan.id'))
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True)
weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
type: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
@@ -135,7 +174,7 @@ class Airport(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
openaip_id: Mapped[str] = mapped_column(String(32), nullable=False, unique=True)
openaip_id: Mapped[str] = mapped_column(String(32), nullable=True, unique=True)
icao_code: Mapped[str] = mapped_column(String(8), nullable=False)
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=True)
@@ -145,6 +184,8 @@ class Airport(BaseModel):
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 # noqa
runways: Mapped[list['Runway']] = relationship('Runway')
frequencies: Mapped[list['Frequency']] = relationship()
created_by: Mapped['User'] = relationship()
@@ -286,22 +327,6 @@ class Aircraft(BaseModel):
organization: Mapped['Organization'] = relationship()
flights: Mapped[Set['Flight']] = relationship()
created_by: Mapped['User'] = relationship()
# notes: Mapped['AircraftNotes'] = relationship()
# class AircraftNotes(BaseModel):
# __tablename__ = "aircraft_notes"
#
# id: Mapped[int] = mapped_column(primary_key=True)
# aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False)
# name: Mapped[str] = mapped_column(String(128), nullable=False)
# description: Mapped[str] = mapped_column(Text, nullable=False)
# is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
# created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
# created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
#
# created_by: Mapped['User'] = relationship()
# aircraft: Mapped['Aircraft'] = relationship()
class Organization(BaseModel):
@@ -317,15 +342,15 @@ class Organization(BaseModel):
created_by: Mapped['User'] = relationship()
class FlightTrack(BaseModel):
__tablename__ = "flight_track"
class FlightTurnPoint(BaseModel):
__tablename__ = "flight_turn_point"
id: Mapped[int] = mapped_column(primary_key=True)
flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False)
point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True)
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
landing_duration: Mapped[int] = mapped_column(Integer, nullable=True)
order: Mapped[int] = mapped_column(Integer)
order: Mapped[int] = mapped_column(Integer, index=True)
flight: Mapped['Flight'] = relationship()
point_of_interest: Mapped['PointOfInterest'] = relationship()
@@ -382,8 +407,10 @@ class Flight(BaseModel):
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)
gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True)
has_terrain_elevation: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
flight_plan_id: Mapped[int] = mapped_column(Integer, ForeignKey('flight_plan.id'), nullable=True)
track_id: Mapped[int] = mapped_column(Integer, ForeignKey('track.id'), nullable=True)
gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True) # deprecated
has_terrain_elevation: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # deprecated
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey('aircraft.id'))
takeoff_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
landing_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
@@ -397,14 +424,14 @@ class Flight(BaseModel):
landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id])
weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[landing_weather_info_id])
weather_info_takeoff: Mapped[WeatherInfo] = relationship(foreign_keys=[takeoff_weather_info_id])
track: Mapped['FlightTrack'] = relationship()
turn_points: Mapped[list['FlightTurnPoint']] = relationship()
event: Mapped['Event'] = relationship()
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_has_copilot)
aircraft: Mapped['Aircraft'] = relationship()
photos: Mapped[List['Photo']] = relationship(foreign_keys=[Photo.flight_id])
created_by: Mapped['User'] = relationship()
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
track: Mapped['Track'] = relationship()
class Copilot(BaseModel):
__tablename__ = "copilot"
+24
View File
@@ -0,0 +1,24 @@
import asyncio
def retryable(func):
max_retries = 5
async def func_wrapper(*args, **kwargs):
for i in range(1, max_retries):
try:
return await func(*args, **kwargs)
except Exception as ex:
if i > max_retries:
raise ex
sleep_time = i * 2
print(
"Error while getting the data from source:"
" decorator is going to sleep for %s seconds"
" and retry it again (%s/%s)" % (
sleep_time, i, max_retries
)
)
await asyncio.sleep(sleep_time)
return func_wrapper
+1 -2
View File
@@ -11,8 +11,7 @@ from graphql_schema.schema import GraphQLContext, schema
def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
if not APP_DEBUG:
return
if APP_DEBUG:
debug_router = APIRouter()
@debug_router.get("/graphql/autologin")
+2
View File
@@ -33,6 +33,8 @@ class LoginEndpoint(AuthEndpoint):
access_token = self.access_security.create_access_token(subject=subject)
refresh_token = self.refresh_security.create_refresh_token(subject=subject)
# TODO: SameSite na refresh_token_cookie!
# https://medium.com/swlh/how-the-new-chrome-80-cookie-rule-samesite-none-secure-affects-web-development-c06380220ced
# self.access_security.set_access_cookie(resp, access_token)
self.refresh_security.set_refresh_cookie(
resp, refresh_token,
+73 -13
View File
@@ -1,27 +1,87 @@
from typing import List, Dict
import dataclasses
from typing import Optional
import aiohttp
from database import models
from decorators.retryable import retryable
from utils.list_utils import chunks
@dataclasses.dataclass
class ElevationInfo:
lat: float
lng: float
elevation: float
id: Optional[int] = None
class ElevationAPI:
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup"
ELEVATION_ENDPOINT: str
def get_request(self, points: List[Dict[str, float]]):
return {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]}
def get_request(self, session: aiohttp.ClientSession, points: list[dict[str, float]]):
...
async def call_api(self, points: List[Dict[str, float]]):
@retryable
async def call_api(self, points: list[dict[str, float]]):
async with aiohttp.ClientSession() as session:
async with session.post(self.ELEVATION_ENDPOINT, json=self.get_request(points)) as resp:
async with self.get_request(session, points) as resp:
resp.raise_for_status()
return await resp.json()
async def get_elevation_for_points(self, points: List[Dict[str, float]]) -> List[dict]:
class OpenElevationAPI(ElevationAPI):
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup"
def get_request(self, session: aiohttp.ClientSession, points: list[dict[str, float]]):
data = {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]}
return session.post(self.ELEVATION_ENDPOINT, json=data)
async def get_elevation_for_points(self, points: list[dict[str, float]]) -> list[ElevationInfo]:
response = await self.call_api(points)
return [{
"lat": loc['latitude'],
"lng": loc['longitude'],
"elevation": loc['elevation']
} for loc in response['results']]
elevation = []
for i, point in enumerate(points):
response_point = response['results'][i]
elevation.append(ElevationInfo(**point, elevation=response_point['elevation']))
return elevation
elevation_api = ElevationAPI()
class OpenMeteoElevationAPI(ElevationAPI):
ELEVATION_ENDPOINT = "https://api.open-meteo.com/v1/elevation"
def get_request(self, session: aiohttp.ClientSession, points: list[dict[str, float]]):
latitudes = ",".join(str(point['lat']) for point in points)
longitudes = ",".join(str(point['lng']) for point in points)
url = f"{self.ELEVATION_ENDPOINT}?latitude={latitudes}&longitude={longitudes}"
return session.get(url)
async def get_elevation_for_points(self, points: list[dict[str, float]]) -> list[ElevationInfo]:
elevation = []
for chunk in chunks(points, n=100):
response = await self.call_api(chunk)
chunk_elevation = []
for i, point in enumerate(chunk):
chunk_elevation.append(ElevationInfo(**point, elevation=response['elevation'][i]))
elevation += chunk_elevation
return elevation
class CombinedElevationAPI:
async def get_elevation_for_points(self, points: list[dict[str, float]]) -> list[ElevationInfo]:
for api in (OpenMeteoElevationAPI(), OpenElevationAPI()):
elevation = await api.get_elevation_for_points(points)
if elevation:
return elevation
async def update_track_points_elevation(db, track_points: list[models.TrackPoint]):
points = [{"id": p.id, "lat": p.gps_latitude, "lng": p.gps_longitude} for p in track_points]
elevation = await elevation_api.get_elevation_for_points(points)
for point in elevation:
await models.TrackPoint.update(db, {"terrain_elevation": point.elevation}, id=point.id)
elevation_api = CombinedElevationAPI()
-34
View File
@@ -1,34 +0,0 @@
import math
EARTH_RADIUS = 6378.1 # Radius of the Earth
def get_bearing(center_point: tuple[float, float], end_point: tuple[float, float]):
lat3 = math.radians(end_point[0])
long3 = math.radians(end_point[1])
lat1 = math.radians(center_point[0])
long1 = math.radians(center_point[1])
dLon = long3 - long1
X = math.cos(lat3) * math.sin(dLon)
Y = math.cos(lat1) * math.sin(lat3) - math.sin(lat1) * math.cos(lat3) * math.cos(dLon)
end_brng = math.atan2(X, Y)
return end_brng
def get_distance(a: tuple[float, float], b: tuple[float, float]):
lat1 = math.radians(a[0])
lon1 = math.radians(a[1])
lat2 = math.radians(b[0])
lon2 = math.radians(b[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return EARTH_RADIUS * c
+8 -42
View File
@@ -1,9 +1,7 @@
from collections import defaultdict
from datetime import datetime
from datetime import datetime, timedelta
from typing import List, Dict, Any
from aiocache import cached
from lxml import etree
from lxml.etree import _ElementTree
class GPXParser:
@@ -68,50 +66,47 @@ class GPXParser:
def run_xpath(self, path: str):
return self.gpx.xpath(path, namespaces=self.namespace)
@cached()
async def get_times_all(self):
nodes = self.run_xpath("//gpx:trkpt/gpx:time")
return [datetime.fromisoformat(node.text).astimezone() for node in nodes]
@cached()
async def get_times(self):
times = await self.get_times_all()
return await self.sample_times(times)
@cached()
async def get_total_duration(self) -> timedelta:
times = await self.get_times_all()
return times[-1] - times[0]
async def get_coordinates(self) -> List[Dict[str, float]]:
return await self.average_coordinates(await self.get_coordinates_all())
@cached()
async def get_coordinates_all(self) -> List[Dict[str, float]]:
nodes = self.run_xpath("//gpx:trkpt")
return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
@cached()
async def get_speed(self) -> List[float]:
nodes = self.run_xpath("//gpx:speed")
return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached()
async def get_magnetic_variation(self) -> List[float]:
nodes = self.run_xpath("//gpx:magvar")
return await self.average_sample_numbers([int(node.text) for node in nodes])
@cached()
async def get_altitude(self) -> List[float]:
nodes = self.run_xpath("//gpx:ele")
return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached()
async def get_terrain_elevation(self) -> List[float]:
nodes = self.run_xpath("//gpx:terrain_elevation")
return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached()
async def get_max_speed(self):
return max(await self.get_speed()) or 0
@cached()
async def get_min_speed(self):
return min(await self.get_speed()) or 0
async def get_avg_speed(self):
speeds = await self.get_speed()
if not speeds:
@@ -119,38 +114,9 @@ class GPXParser:
return round(sum(speeds) / len(speeds), 2)
@cached()
async def get_max_altitude(self):
return max(await self.get_altitude()) or 0
@cached()
async def get_avg_altitude(self):
altitudes = await self.get_altitude()
return round(sum(altitudes) / len(altitudes), 2)
def add_terrain_elevation(self, points_with_elevation: List[Dict[str, float]]):
track_points = self.run_xpath("//gpx:trkpt")
# TODO: open elevation API umi jen presnost na 6 desetinnych mist!
track_points_index = {(float(n.attrib['lat']), float(n.attrib['lon'])): n for n in track_points}
for point_with_elevation in points_with_elevation:
lat = point_with_elevation['lat']
lng = point_with_elevation['lng']
elevation = point_with_elevation['elevation']
target_node = track_points_index.get((lat, lng))
if target_node is None:
continue
extensions = target_node.find("./extensions", self.gpx.getroot().nsmap)
if extensions is not None:
extensions.append(etree.XML(f"<terrain_elevation>{elevation}</terrain_elevation>"))
else:
target_node.append(
etree.XML(f"<extensions><terrain_elevation>{elevation}</terrain_elevation></extensions>")
)
return self.gpx
def write(self, tree: _ElementTree, output: str):
tree.write(output)
+1 -1
View File
@@ -8,7 +8,7 @@ import aiohttp
from sqlalchemy import delete
from database import models
from database.transaction import get_session
from external.gps_utils import get_distance, get_bearing, EARTH_RADIUS
from utils.gps import get_distance, get_bearing, EARTH_RADIUS
# https://pyopenair.readthedocs.io/en/latest/openair.html
+2 -1
View File
@@ -3,6 +3,7 @@ import urllib.parse
from typing import Tuple, Dict
import aiohttp
from aiocache import cached
from decorators.retryable import retryable
class Weather:
@@ -38,6 +39,7 @@ class Weather:
return f"{url}{query_string}"
@cached(ttl=6 * 3600)
@retryable
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)
@@ -52,7 +54,6 @@ class Weather:
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")
@@ -10,8 +10,8 @@ async def load_flight_durations(ids: List[int]):
select(
models.Flight.id,
func.timediff(models.Flight.landing_datetime, models.Flight.takeoff_datetime).label("diff"),
func.coalesce(func.sum(models.FlightTrack.landing_duration), 0).label("landing_duration")
).join(models.Flight.track, isouter=True)
func.coalesce(func.sum(models.FlightTurnPoint.landing_duration), 0).label("landing_duration")
).join(models.Flight.turn_points, isouter=True)
.group_by(models.Flight.id)
.filter(models.Flight.id.in_(ids))
+32 -5
View File
@@ -47,6 +47,24 @@ reporting_points_dataloader = DataLoader(
).load, cache=False
)
runways_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Runway,
relationship_column=models.Airport.id,
extra_join=[models.Airport.runways],
order_by=[models.Runway.designator]
).load, cache=False
)
frequencies_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Frequency,
relationship_column=models.Airport.id,
extra_join=[models.Airport.frequencies],
order_by=[models.Frequency.frequency]
).load, cache=False
)
flights_by_copilot_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
@@ -80,7 +98,7 @@ flight_by_poi_dataloader = DataLoader(
models.Flight,
relationship_column=models.PointOfInterest.id,
order_by=[models.Flight.takeoff_datetime.desc()],
extra_join=[models.Flight.track, models.PointOfInterest]
extra_join=[models.Flight.turn_points, models.PointOfInterest]
).load,
cache=False
)
@@ -137,11 +155,20 @@ poi_photos_dataloader = DataLoader(
).load,
cache=False
)
flight_track_dataloader = DataLoader(
flight_turn_points_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.FlightTrack,
relationship_column=models.FlightTrack.flight_id,
order_by=[models.FlightTrack.order]
models.FlightTurnPoint,
relationship_column=models.FlightTurnPoint.flight_id,
order_by=[models.FlightTurnPoint.order]
).load,
cache=False
)
track_points_dataloder = DataLoader(
load_fn=MultiModelsDataloader(
models.TrackPoint,
relationship_column=models.TrackPoint.track_id,
order_by=[models.TrackPoint.timestamp, models.TrackPoint.id]
).load,
cache=False
)
@@ -15,6 +15,7 @@ aircraft_dataloader = create_dataloader(models.Aircraft)
event_dataloader = create_dataloader(models.Event)
organizations_dataloader = create_dataloader(models.Organization)
airport_weather_info_loader = create_dataloader(models.WeatherInfo)
track_dataloader = create_dataloader(models.Track)
poi_dataloader = create_dataloader(models.PointOfInterest)
poi_type_dataloader = create_dataloader(models.PointOfInterestType)
flight_dataloader = create_dataloader(models.Flight)
+1 -1
View File
@@ -11,7 +11,7 @@ from graphql_schema.entities.types.types import Airport
class AirportQueries:
@strawberry.field()
@error_logging
@authenticated_user_only()
# @authenticated_user_only()
async def airports(root, info) -> List[Airport]:
return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id)
-2
View File
@@ -1,7 +1,5 @@
from typing import Optional
import strawberry
from graphql import GraphQLError
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
+19 -9
View File
@@ -1,11 +1,10 @@
from typing import List
from typing import List, Optional
import strawberry
from strawberry.types import Info
from database import models
from decorators.error_logging import error_logging
from decorators.endpoints import authenticated_user_only, allow_public
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.resolvers.flight_plan import FlightPlanMutationResolver
from decorators.error_logging import error_logging
from graphql_schema.entities.helpers.detail import get_detail_filters
from graphql_schema.entities.resolvers.flight_plan import FlightPlanMutationResolver, FlightPlanQueryResolver
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
from graphql_schema.entities.types.types import FlightPlan
@@ -16,15 +15,26 @@ class FlightPlanQueries:
@error_logging
@authenticated_user_only()
async def flight_plans(root, info: Info) -> List[FlightPlan]:
return await BaseQueryResolver(FlightPlan, models.FlightPlan).get_list(info.context.user_id)
return await FlightPlanQueryResolver().get_list(info.context.user_id)
@strawberry.field()
@error_logging
@allow_public
async def flight_plan(root, info: Info, id: int) -> FlightPlan:
return await BaseQueryResolver(FlightPlan, models.FlightPlan).get_one(
async def flight_plan(
root,
info: Info,
id: int,
username: Optional[str] = None,
public: Optional[bool] = False
) -> FlightPlan:
filter_params = get_detail_filters(id)
if username:
filter_params['username'] = username
return await FlightPlanQueryResolver().get_one(
user_id=info.context.user_id,
object_id=id
only_public=public,
**filter_params
)
@@ -2,7 +2,7 @@ from typing import Optional
from graphql import GraphQLError
def get_detail_filters(id: Optional[int], url_slug: Optional[str]) -> dict:
def get_detail_filters(id: Optional[int] = None, url_slug: Optional[str] = None) -> dict:
filter_params = {}
if id:
filter_params['object_id'] = id
+51 -105
View File
@@ -1,14 +1,14 @@
import asyncio
from typing import List, Optional
from sqlalchemy import delete, insert, select, func, text
import random
from typing import Optional
from sqlalchemy import delete, insert
from sqlalchemy.ext.asyncio import AsyncSession
from strawberry.file_uploads import Upload
from background_jobs.elevation import add_terrain_elevation_to_flight
from background_jobs.weather import download_weather
from utils.flight_track_helpers import handle_upload_gpx, save_track_from_gpx_to_db, extract_basic_flight_info_from_gpx
from background_jobs.weather import download_weather_for_flight
from database import models
from database.models import flight_has_copilot
from database.transaction import get_session
from external.gpx_parser import GPXParser
from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.types.mutation_input import (
@@ -16,9 +16,8 @@ from graphql_schema.entities.types.mutation_input import (
)
from graphql_schema.entities.types.types import Flight
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import delete_file
from utils.file import handle_file_upload
from utils.str_utils import random_str
from utils.upload import handle_file_upload
class FlightQueryResolver(BaseQueryResolver):
@@ -28,17 +27,11 @@ class FlightQueryResolver(BaseQueryResolver):
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
url_slug: Optional[str] = None,
only_public: Optional[bool] = False,
*args,
**kwargs
):
filters = {}
if object_id:
filters['object_id'] = object_id
if url_slug:
filters['url_slug'] = url_slug
filters = {key: kwargs[key] for key in ("object_id", "url_slug") if key in kwargs}
query = super().get_query(
user_id,
@@ -62,8 +55,8 @@ class FlightQueryResolver(BaseQueryResolver):
if kwargs.get("point_of_interest_id"):
query = (
query.join(models.Flight.track)
.filter(models.FlightTrack.point_of_interest_id == kwargs["point_of_interest_id"])
query.join(models.Flight.turn_points)
.filter(models.FlightTurnPoint.point_of_interest_id == kwargs["point_of_interest_id"])
)
if kwargs.get('username'):
@@ -79,55 +72,13 @@ class FlightMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(Flight, models.Flight)
async def get_airport_id_by_gps(self, gps_lat: float, gps_lng: float) -> Optional[int]:
async with (get_session() as db):
query = (
select(models.Airport, func.coalesce(6371 * func.acos(
func.cos(func.radians(gps_lat)) *
func.cos(func.radians(models.Airport.gps_latitude)) *
func.cos(func.radians(models.Airport.gps_longitude) - func.radians(gps_lng)) +
func.sin(func.radians(gps_lat)) *
func.sin(func.radians(models.Airport.gps_latitude))
), 9999).label("distance"))
.filter(models.Airport.use_in_gpx_guess.is_(True))
.order_by("distance")
.having(text("distance < 1"))
.limit(1)
)
data = (await db.execute(query)).one_or_none()
if data:
airport, distance = data
return airport.id
return None
async def extract_data_from_gpx(self, gpx_filename: str) -> dict:
data = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
times, coordinates = await asyncio.gather(
data.get_times(),
data.get_coordinates()
)
takeoff_airport_id, landing_airport_id = await asyncio.gather(
self.get_airport_id_by_gps(coordinates[0]['lat'], coordinates[0]['lng']),
self.get_airport_id_by_gps(coordinates[-1]['lat'], coordinates[-1]['lng']),
)
return {
"takeoff_airport_id": takeoff_airport_id,
"landing_airport_id": landing_airport_id,
"takeoff_datetime": times[0],
"landing_datetime": times[-1],
}
async def create(self, context, input: CreateFlightInput) -> Flight:
data = input.to_dict()
user_id = context.user_id
if input.gpx_track_file:
data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, context=context)
data_from_gpx = await self.extract_data_from_gpx(data['gpx_track_filename'])
data['gpx_track_filename'] = await handle_file_upload(input.gpx_track_file, FLIGHT_GPX_TRACK_PATH)
data_from_gpx = await extract_basic_flight_info_from_gpx(data['gpx_track_filename'])
data.update(data_from_gpx)
else:
async with get_session() as db:
@@ -148,22 +99,19 @@ class FlightMutationResolver(BaseMutationResolver):
data.update({
"aircraft_id": aircraft_id,
"has_terrain_elevation": False,
"name": "",
"name": input.name or '',
"description": "",
"created_by_id": context.user_id
})
flight = await self._do_create(db, data)
context.background_tasks.add_task(
download_weather,
flight_id=flight.id, airport_id=flight.takeoff_airport_id, date_time=flight.takeoff_datetime,
type_="takeoff"
)
context.background_tasks.add_task(
download_weather,
flight_id=flight.id, airport_id=flight.landing_airport_id, date_time=flight.landing_datetime,
type_="landing"
)
if input.track is not None:
await handle_track_edit(db=db, flight_id=flight.id, track=input.track, user_id=user_id)
if data['gpx_track_filename']:
await save_track_from_gpx_to_db(gpx_filename=data['gpx_track_filename'], flight_id=flight.id)
schedule_background_tasks(flight.id, data, context)
return flight
@@ -177,40 +125,23 @@ class FlightMutationResolver(BaseMutationResolver):
data = input.to_dict()
if input.gpx_track_file is not None:
data['gpx_track_filename'] = await handle_upload_gpx(
gpx_track=input.gpx_track_file,
context=context,
original_gpx_filename=flight_data['gpx_track_filename']
)
data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, flight_id=flight_id)
async with get_session() as db:
if input.takeoff_airport:
takeoff_airport_id = await handle_combobox_save(
data['takeoff_airport_id'] = await handle_combobox_save(
db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code",
extra_data={"name": input.takeoff_airport.name}
)
data['takeoff_airport_id'] = takeoff_airport_id
data['takeoff_datetime'] = input.takeoff_datetime or flight_data['takeoff_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=takeoff_airport_id, date_time=data['takeoff_datetime'],
type_="takeoff"
)
if input.landing_airport:
landing_airport_id = await handle_combobox_save(
data['landing_airport_id'] = await handle_combobox_save(
db, models.Airport, input.landing_airport, user_id, name_column="icao_code",
extra_data={"name": input.landing_airport.name}
)
data['landing_airport_id'] = landing_airport_id
data['landing_datetime'] = input.landing_datetime or flight_data['landing_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=landing_airport_id, date_time=data['landing_datetime'],
type_="landing"
)
if input.aircraft is not None:
data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft)
@@ -239,21 +170,27 @@ class FlightMutationResolver(BaseMutationResolver):
token=random_str(64)
))
return await self._do_update(db, flight_data, data)
flight_model = await self._do_update(db, flight_data, data)
schedule_background_tasks(flight_id, data, context)
return flight_model
async def handle_upload_gpx(gpx_track: Upload, context, original_gpx_filename: Optional[str] = None):
if original_gpx_filename:
delete_file(FLIGHT_GPX_TRACK_PATH + "/" + original_gpx_filename, silent=True)
filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH)
context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=id, gpx_filename=filename)
return filename
def schedule_background_tasks(flight_id: int, flight_data: dict, context) -> None:
context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=flight_id)
context.background_tasks.add_task(
download_weather_for_flight, flight_id=id, airport_id=flight_data['takeoff_airport_id'],
date_time=flight_data['takeoff_datetime'], type_="takeoff"
)
context.background_tasks.add_task(
download_weather_for_flight, flight_id=id, airport_id=flight_data['landing_airport_id'],
date_time=flight_data['landing_datetime'], type_="landing"
)
async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackItemInput], user_id: int):
await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight_id))
async def handle_track_edit(db: AsyncSession, flight_id: int, track: list[TrackItemInput], user_id: int):
await db.execute(delete(models.FlightTurnPoint).filter(models.FlightTurnPoint.flight_id == flight_id))
order = 0
for item in track:
@@ -262,17 +199,26 @@ async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackI
if item.point_of_interest:
poi_id = await handle_combobox_save(
db, models.PointOfInterest, item.point_of_interest, user_id, extra_data={"description": ""}
db, models.PointOfInterest, item.point_of_interest, user_id, extra_data={
"description": "",
'gps_latitude': item.gps_latitude,
'gps_longitude': item.gps_longitude
}
)
if item.airport:
airport_id = await handle_combobox_save(
db, models.Airport, item.airport, user_id,
name_column="icao_code",
extra_data={"name": item.airport.name}
extra_data={
"name": item.airport.name,
'gps_latitude': item.gps_latitude,
'gps_longitude': item.gps_longitude,
'openaip_id': f'custom-{random.randint(0, 999999)}'
}
)
await models.FlightTrack.create(
await models.FlightTurnPoint.create(
db,
data={
"flight_id": flight_id,
@@ -1,19 +1,51 @@
import asyncio
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy.dialects.mysql import insert
from background_jobs.flight_plan_track import generate_track_for_flight_plan
from database import models
from database.models import flight_plan_has_copilot
from database.transaction import get_session
from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.resolvers.flight import handle_aircraft_save
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
from graphql_schema.entities.types.types import FlightPlan
from utils.list_utils import evenly_spaced_elements
from utils.str_utils import random_str
class FlightPlanQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
def get_query(
self,
user_id: Optional[int] = None,
only_public: Optional[bool] = False,
object_id: Optional[int] = None,
*args,
**kwargs
):
filters = {"object_id": object_id} if object_id else {}
query = super().get_query(
user_id,
**filters,
order_by=[models.FlightPlan.planned_takeoff_datetime.desc(), models.FlightPlan.id.desc()],
only_public=only_public,
only_my=not only_public
)
if kwargs.get('username'):
query = (
query.join(models.FlightPlan.created_by)
.filter(models.User.public_username == kwargs['username'])
)
return query
class FlightPlanMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
@@ -23,9 +55,17 @@ class FlightPlanMutationResolver(BaseMutationResolver):
position = 0
for marker in markers:
if marker.type == 'poi':
assert bool(marker.point_of_interest_id)
if marker.type == 'airport':
assert bool(marker.airport_id)
await models.FlightPlanMarker.create(db, data={
"position": position,
"flight_plan_id": flight_plan.id,
"airport_id": marker.airport_id,
"point_of_interest_id": marker.point_of_interest_id,
"type": marker.type,
"name": marker.name,
"gps_latitude": marker.gps_latitude,
@@ -79,7 +119,10 @@ class FlightPlanMutationResolver(BaseMutationResolver):
if data.markers:
markers = data.markers
else:
markers = (await db.scalars(select(models.FlightPlanMarker).filter(models.FlightPlanMarker.flight_plan_id == id))).all()
markers = (await db.scalars(
select(models.FlightPlanMarker)
.filter(models.FlightPlanMarker.flight_plan_id == id)
)).all()
used_markers = evenly_spaced_elements(markers, 5)
input_data['name'] = " - ".join(m.name for m in used_markers)
@@ -93,11 +136,7 @@ class FlightPlanMutationResolver(BaseMutationResolver):
)
flight_plan = await self._do_update(db, obj=flight_plan_model, data=input_data)
await generate_track_for_flight_plan(flight_plan.id)
return flight_plan
def evenly_spaced_elements(lst: list, count: int) -> list:
if count > len(lst):
return lst
interval = (len(lst) - 1) / (count - 1) if count > 1 else 0
return [lst[int(round(i * interval))] for i in range(count)]
@@ -16,7 +16,7 @@ from graphql_schema.entities.types.types import Photo
from paths import get_photo_basepath
from utils.file import delete_file
from utils.image import PhotoEditor, parse_exif_info
from utils.upload import handle_file_upload
from utils.file import handle_file_upload
class PhotoQueryResolver(BaseQueryResolver):
@@ -138,11 +138,14 @@ class EditFlightInput(BaseGraphqlInputType):
@strawberry.input()
class CreateFlightInput(BaseGraphqlInputType):
aircraft: ComboboxInput
flight_plan_id: Optional[int] = None
name: Optional[str] = None
gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
landing_airport: Optional[ComboboxInput] = None
takeoff_airport: Optional[ComboboxInput] = None
takeoff_datetime: Optional[datetime] = None
landing_datetime: Optional[datetime] = None
track: Optional[List[TrackItemInput]] = None
@strawberry.input()
@@ -150,6 +153,8 @@ class TrackItemInput:
point_of_interest: Optional[ComboboxInput] = None
airport: Optional[ComboboxInput] = None
landing_duration: Optional[int] = None
gps_latitude: Optional[float] = None
gps_longitude: Optional[float] = None
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename'])
+48 -58
View File
@@ -1,30 +1,28 @@
from __future__ import annotations
import math
from datetime import datetime
from typing import Optional, List
import strawberry
from database import models
from decorators.endpoints import authenticated_user_only
from external.gps_utils import get_bearing, get_distance
from utils.gps import get_bearing, get_distance
from external.gpx_parser import GPXParser
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
from graphql_schema.dataloaders.multi_models import (
poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_track_dataloader,
poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_turn_points_dataloader,
photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader,
aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_dataloader,
flights_by_copilot_dataloader, public_flights_by_event_dataloader, public_flights_by_copilot_dataloader,
photo_copilots_dataloader, photos_aircraft_dataloader, copilots_in_photo_dataloader, flight_plan_markers_dataloader,
reporting_points_dataloader, flight_plan_copilots_dataloader
reporting_points_dataloader, flight_plan_copilots_dataloader, runways_dataloader, frequencies_dataloader, track_points_dataloder
)
from graphql_schema.dataloaders.single_model import (
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader,
airport_weather_info_loader, organizations_dataloader, flight_dataloader, photo_adjustment_dataloader,
photo_dataloader, user_dataloader
photo_dataloader, user_dataloader, track_dataloader
)
from graphql_schema.permissions import IsAuthenticated
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH, airport_icon_url, get_public_url
@strawberry.type
@@ -33,18 +31,6 @@ class Point:
lng: float
@strawberry.type
class GPXTrack:
coordinates: List[Point]
speed: List[float]
altitude: List[float]
magnetic_variation: List[float]
terrain_elevation: List[float]
time: List[datetime]
max_speed: float
avg_speed: float
max_altitude: float
avg_altitude: float
@strawberry_sqlalchemy_type(models.ReportingPoint)
@@ -52,14 +38,24 @@ class ReportingPoint:
pass
@strawberry_sqlalchemy_type(models.Runway)
class Runway:
pass
@strawberry_sqlalchemy_type(models.Frequency)
class Frequency:
pass
@strawberry_sqlalchemy_type(models.Airport)
class Airport:
reporting_points: List[ReportingPoint] = strawberry.field(
resolver=lambda root: reporting_points_dataloader.load(root.id)
)
# TODO: loader na ikonu
# TODO: loader na RWY a frekvence
runways: List[Runway] = strawberry.field(resolver=lambda root: runways_dataloader.load(root.id))
frequencies: List[Frequency] = strawberry.field(resolver=lambda root: frequencies_dataloader.load(root.id))
icon_url: str = strawberry.field(resolver=lambda root: airport_icon_url(root.icao_code))
@strawberry.type
@@ -78,8 +74,8 @@ class Airspace:
)
@strawberry_sqlalchemy_type(models.FlightTrack)
class FlightTrack:
@strawberry_sqlalchemy_type(models.FlightTurnPoint)
class FlightTurnPoint:
point_of_interest: Optional[PointOfInterest] = strawberry.field(
resolver=lambda root: poi_dataloader.load(root.point_of_interest_id)
)
@@ -123,6 +119,20 @@ class Photo:
)
@strawberry_sqlalchemy_type(models.TrackPoint)
class TrackPoint:
coordinates: Point = strawberry.field(resolver=lambda root: Point(lat=root.gps_latitude, lng=root.gps_longitude))
@strawberry_sqlalchemy_type(models.Track, exclude_fields=['bounds'])
class Track:
bounds: list[tuple[float, float]]
map_bounds: list[Point] = strawberry.field(
resolver=lambda root: [Point(lat=point[1], lng=point[0]) for point in root.bounds]
)
points: list[TrackPoint] = strawberry.field(resolver=lambda root: track_points_dataloder.load(root.id))
@strawberry_sqlalchemy_type(models.Flight)
class Flight:
def __init__(self, **kwargs):
@@ -131,28 +141,6 @@ class Flight:
for key, value in kwargs.items():
setattr(self, key, value)
async def load_gpx_track(root):
if not root.gpx_track_filename:
return None
try:
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{root.gpx_track_filename}")
except OSError:
return None
return GPXTrack(
coordinates=[Point(**point) for point in await gpx_parser.get_coordinates()],
speed=await gpx_parser.get_speed(),
altitude=await gpx_parser.get_altitude(),
terrain_elevation=await gpx_parser.get_terrain_elevation(),
time=await gpx_parser.get_times(),
max_speed=await gpx_parser.get_max_speed(),
avg_speed=await gpx_parser.get_avg_speed(),
max_altitude=await gpx_parser.get_max_altitude(),
avg_altitude=await gpx_parser.get_avg_altitude(),
magnetic_variation=await gpx_parser.get_magnetic_variation(),
)
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
async def load_copilots(root):
return await flight_copilots_dataloader.load(root.id)
@@ -172,7 +160,7 @@ class Flight:
resolver=lambda root: airport_dataloader.load(root.landing_airport_id)
)
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
track: List[FlightTrack] = strawberry.field(resolver=lambda root: flight_track_dataloader.load(root.id))
turn_points: List[FlightTurnPoint] = strawberry.field(resolver=lambda root: flight_turn_points_dataloader.load(root.id))
takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(
resolver=lambda root: airport_weather_info_loader.load(root.takeoff_weather_info_id)
)
@@ -180,11 +168,11 @@ class Flight:
resolver=lambda root: airport_weather_info_loader.load(root.landing_weather_info_id)
)
photos: List[Photo] = strawberry.field(resolver=lambda root: photos_dataloader.load(root.id))
gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track)
duration_min_calculated: int = strawberry.field(
resolver=lambda root: flight_duration_dataloader.load(root.id)
track: Optional[Track] = strawberry.field(resolver=lambda root: track_dataloader.load(root.track_id))
duration_min_calculated: int = strawberry.field(resolver=lambda root: flight_duration_dataloader.load(root.id))
social_image_url: Optional[str] = strawberry.field(
resolver=lambda root: get_public_url(f'photos/{root.id}/title_photo.jpg')
)
debriefing_notes: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated],
resolver=lambda root: root.debriefing_notes
@@ -197,7 +185,7 @@ class FlightPlanMarker:
@strawberry.type
class FlightPlanTrack:
class FlightPlanNavlog:
bearing: Optional[int]
distance: Optional[float]
from_: FlightPlanMarker = strawberry.field(name="from")
@@ -206,11 +194,11 @@ class FlightPlanTrack:
@strawberry_sqlalchemy_type(model=models.FlightPlan)
class FlightPlan:
async def resolve_track(self):
async def resolve_navlog(self):
markers = await self.markers()
if len(markers) == 1:
return [FlightPlanTrack(bearing=None, distance=None, from_=markers[0], to=None)]
return [FlightPlanNavlog(bearing=None, distance=None, from_=markers[0], to=None)]
navigation = []
for i in range(len(markers) - 1):
@@ -220,7 +208,7 @@ class FlightPlan:
point_from = (from_.gps_latitude, from_.gps_longitude)
point_to = (to.gps_latitude, to.gps_longitude)
navigation.append(FlightPlanTrack(
navigation.append(FlightPlanNavlog(
bearing=round(math.degrees(get_bearing(point_from, point_to)) % 360),
distance=round(get_distance(point_from, point_to), 1),
from_=from_,
@@ -235,9 +223,8 @@ class FlightPlan:
markers: list[FlightPlanMarker] = strawberry.field(
resolver=lambda root: flight_plan_markers_dataloader.load(root.id)
)
track: Optional[list[FlightPlanTrack]] = strawberry.field(
resolver=resolve_track
)
navlog: Optional[list[FlightPlanNavlog]] = strawberry.field(resolver=resolve_navlog)
track: Optional[Track] = strawberry.field(resolver=lambda root: track_dataloader.load(root.track_id))
copilots: List[Copilot] = strawberry.field(resolver=load_copilots)
aircraft: Optional[Aircraft] = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id))
@@ -285,6 +272,9 @@ class Organization:
class User:
avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root))
title_image_url: str = strawberry.field(resolver=lambda root: get_title_image_url(root))
social_image_url: Optional[str] = strawberry.field(
resolver=lambda root: get_public_url(f'profile/{root.id}/title_photo.jpg')
)
organizations: List[Organization] = strawberry.field(
resolver=lambda root: user_organizations_dataloader.load(root.id)
)
+1 -1
View File
@@ -11,7 +11,7 @@ from decorators.error_logging import error_logging
from database.transaction import get_session
from graphql_schema.entities.types.types import User
from utils.file import delete_file
from utils.upload import handle_file_upload
from utils.file import handle_file_upload
@strawberry.type
+2 -1
View File
@@ -9,7 +9,7 @@ from starlette.background import BackgroundTasks
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import Response, JSONResponse
from starlette.staticfiles import StaticFiles
from config import APP_SECRET_KEY, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS
from config import APP_SECRET_KEY, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS, APP_DEBUG
from endpoints.contact import ContactEndpoint, ContactInput
from endpoints.forgotten_password import ForgottenPasswordRequest, ForgottenPasswordEndpoint, ChangeForgottenPassword
from endpoints.graphql import setup_graphql_endpoint
@@ -35,6 +35,7 @@ class App:
access_security = JwtAccessBearerCookie(
secret_key=APP_SECRET_KEY,
auto_error=False,
access_expires_delta=timedelta(days=1) if APP_DEBUG else timedelta(minutes=20)
)
refresh_security = JwtRefreshBearerCookie(
secret_key=APP_SECRET_KEY,
+4
View File
@@ -18,6 +18,10 @@ def get_public_url(filename: Optional[str]) -> str:
return f"{API_URL}/uploads/{filename}" if filename else None
def airport_icon_url(icao_code: str) -> str:
return f"{API_URL}/static/airports/{icao_code}.png"
def get_photo_url(root) -> str:
filename = root.filename if not root.filename_extension else f"{root.filename}.{root.filename_extension}"
return get_public_url(f"photos/{root.flight_id}/{filename}?cache={root.cache_key}")
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import select
from database import models
from database.transaction import get_session
class FlighPlanWeather:
@staticmethod
async def get_plans() -> list:
async with get_session() as db:
markers_without_weather = (await db.execute(
select(models.FlightPlanMarker).filter(models.FlightPlanMarker.weather_info_id.is_(None))
))
flight_plan_ids = {m.flight_plan_id for m in markers_without_weather}
if not flight_plan_ids:
return []
return (await db.execute(
select(models.FlightPlan, models.FlightPlan.markers)
.filter(models.FlightPlan.id.in_(flight_plan_ids))
))
+17 -40
View File
@@ -1,75 +1,52 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from paths import FLIGHT_GPX_TRACK_PATH # noqa
from database import async_session, models # noqa
from external.elevation import elevation_api # noqa
from external.elevation import elevation_api, update_track_points_elevation # noqa
from external.gpx_parser import GPXParser # noqa
from database.transaction import get_session
async def add_elevation_to_photos():
async with async_session() as session:
photos = (await session.scalars(
async with get_session() as db:
photos = (await db.scalars(
select(models.Photo)
.filter(models.Photo.terrain_elevation.is_(None))
)).all()
coordinates = [
{"lat": p.gps_latitude, "lng": p.gps_longitude} for p in photos if p.gps_latitude or p.gps_longitude
{"lat": p.gps_latitude, "lng": p.gps_longitude, "id": p.id} for p in photos if p.gps_latitude or p.gps_longitude
]
photos_by_corrdinates = {(p.gps_latitude, p.gps_longitude): p for p in photos}
if not coordinates:
print("all done")
return
points = await elevation_api.get_elevation_for_points(coordinates)
for point in points:
photo = photos_by_corrdinates[point['lat'], point['lng']]
await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point['elevation']})
await session.flush()
await session.commit()
await models.Photo.update(db_session=db, id=point.id, data={"terrain_elevation": point.elevation})
async def add_elevation_to_tracks():
async with async_session() as session:
flights = (await session.scalars(
select(models.Flight)
.filter(models.Flight.has_terrain_elevation.is_(False))
.filter(models.Flight.gpx_track_filename.isnot(None))
async with get_session() as db:
track_points = (await db.scalars(
select(models.TrackPoint)
.filter(models.TrackPoint.terrain_elevation.is_(None))
)).all()
if not flights:
if not track_points:
print("all done")
return
for flight in flights:
gpx_file = f"{FLIGHT_GPX_TRACK_PATH}/{flight.gpx_track_filename}"
try:
gpx = GPXParser(gpx_file)
except OSError:
print(f"ERROR, cannot open {gpx_file=}, skipping")
continue
coordinates = await gpx.get_coordinates_all()
elevation = await elevation_api.get_elevation_for_points(coordinates)
gpx_with_elevation = gpx.add_terrain_elevation(elevation)
output_name = f"terrain_{flight.gpx_track_filename[-30:]}"
gpx.write(gpx_with_elevation, output=f"{FLIGHT_GPX_TRACK_PATH}/{output_name}")
await models.Flight.update(
db_session=session, obj=flight, data={
"has_terrain_elevation": True,
"gpx_track_filename": output_name
}
)
await session.flush()
await session.commit()
await update_track_points_elevation(db, track_points)
async def run_all():
await asyncio.gather(add_elevation_to_photos(), add_elevation_to_tracks())
await asyncio.gather(
add_elevation_to_photos(),
add_elevation_to_tracks()
)
if __name__ == "__main__":
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import dataclasses
import sys
from collections import defaultdict
from PIL import Image, ImageDraw, ImageFilter
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from database import models # noqa
from database.transaction import get_session # noqa
@dataclasses.dataclass(frozen=True)
class Runway:
heading: int
surface: str
designator: str
@dataclasses.dataclass()
class Airport:
name: str
runways: list[Runway]
async def get_airports():
async with get_session() as db:
airports_data = (await db.scalars(
select(models.Airport)
# .filter(models.Airport.icao_code.in_(["LKJH", "LKLT", "LKBE"]))
)).all()
runways_data = (await db.execute(
select(models.Runway.designator, models.Runway.heading, models.Runway.surface, models.Runway.airport_id)
.distinct()
.order_by(models.Runway.designator)
))
runways = defaultdict(set)
for rwy in runways_data:
runways[rwy.airport_id].add(rwy)
airports = []
for airport in airports_data:
airport_rwy = runways[airport.id]
if not airport_rwy:
continue
airports.append(
Airport(
name=airport.icao_code,
runways=[Runway(heading=rwy.heading, surface=rwy.surface, designator=rwy.designator) for rwy in airport_rwy]
)
)
return airports
def draw_airport(draw, icon_size):
diameter = icon_size // 2
draw.ellipse(
[(diameter // 2, diameter // 2), (icon_size - diameter // 2, icon_size - diameter // 2)],
fill="#cccccc",
outline="black"
)
return draw
# def make_rectangle(l, w, theta, offset=(0, 0)):
# c, s = math.cos(theta), math.sin(theta)
# rect_coords = [(l / 2.0, w / 2.0), (l / 2.0, -w / 2.0), (-l / 2.0, -w / 2.0), (-l / 2.0, w / 2.0)]
# return [(c * x - s * y + offset[0], s * x + c * y + offset[1]) for (x, y) in rect_coords]
#
# def draw_runway(airport_img, runway: Runway, size):
# if runway.heading > 180:
# return
#
# rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
#
# draw = ImageDraw.Draw(rwy)
#
# width = 60
# center = size // 2 - width // 2
#
# colors = {
# "asphalt": "#333333",
# "concrete": "#dddddd",
# "grass": "#00ff00"
# }
#
# draw.rectangle((center, 60, center + width, size-60), fill=colors[runway.surface])
#
# font = ImageFont.truetype('/app/static/Calibri.ttf', 60)
# draw.text((center, size - width), runway.designator, font=font, fill="black")
# rwy = rwy.rotate(-1 * runway.heading, expand=False)
#
# airport_img.paste(rwy, mask=rwy)
# print(f"Kreslim RWY {runway}")
# length = size * 0.9
# width = 40
# vertices = make_rectangle(length, width, theta=runway.heading, offset=(250, 250))
# draw.polygon(vertices, fill="#dddddd", outline="black")
# return draw
def add_edge_border(image, border_size=5, border_color=(255, 0, 0, 255)):
mask = image.split()[3]
edge_mask = mask.filter(ImageFilter.FIND_EDGES)
border_image = Image.new("RGBA", image.size, (0, 0, 0, 0))
border_image.paste(border_color, mask=edge_mask)
for _ in range(border_size - 1):
edge_mask = edge_mask.filter(ImageFilter.MaxFilter(3))
border_image.paste(border_color, mask=edge_mask)
bordered_image = Image.alpha_composite(border_image, image)
return bordered_image
def draw_runways(airport_img, runways: list[Runway], size: int):
rwy_width = 60
rwy_margin = 10
center = size // 2
all_runways_width = len(runways) * rwy_width + (len(runways) - 1) * rwy_margin
start = center - all_runways_width // 2
colors = {
"asphalt": "#777777",
"concrete": "#cccccc",
"grass": "#00aa00"
}
rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
heading_sum = 0
draw = ImageDraw.Draw(rwy)
for runway in runways:
top_left = (start, 80)
bottom_right = (start + rwy_width, size - 80)
draw.rectangle((*top_left, *bottom_right), fill=colors[runway.surface])
# draw.text((start, size - 60), runway.designator, font=font, fill="black")
start += rwy_width + rwy_margin
heading_sum += runway.heading
rwy = rwy.rotate(-1 * heading_sum // len(runways), expand=False)
airport_img.paste(rwy, mask=rwy)
async def generate_icon(airport: Airport):
icon_size = 500
# creating new Image object
img = Image.new("RGBA", (icon_size, icon_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if len(airport.name) == 4:
draw = draw_airport(draw, icon_size)
parallel_runways = defaultdict(list)
for runway in airport.runways:
key = runway.designator[:2]
parallel_runways[key].append(runway)
for designator, runways in parallel_runways.items():
draw_runways(img, runways, icon_size)
img = add_edge_border(img, border_size=10, border_color=(0, 0, 255))
img = img.resize((40, 40), resample=Image.LANCZOS)
img.save(f'/app/static/airports/{airport.name}.png', 'PNG')
# img.save(f'/app/static/airports/{airport.name}.webp', 'WEBP')
print(airport.name, [(rwy.designator, rwy.surface) for rwy in airport.runways])
async def generate_icons():
airports = await get_airports()
for airport in airports:
await generate_icon(airport)
# return
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_icons())
+15
View File
@@ -0,0 +1,15 @@
import asyncio
import sys
sys.path.insert(0, "/app/src")
from background_jobs.flight_plan_track import generate_track_for_flight_plan
async def run_all():
await generate_track_for_flight_plan(11)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(run_all())
+26
View File
@@ -0,0 +1,26 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from utils.flight_track_helpers import save_track_from_gpx_to_db
from database import models
from database.transaction import get_session
async def migrate_gpx():
async with get_session() as db:
flights = (
await db.execute(
select(models.Flight.id, models.Flight.gpx_track_filename)
.filter(models.Flight.gpx_track_filename.is_not(None)))
).all()
flight_tracks = {f.id: f.gpx_track_filename for f in flights}
for id, gpx_filename in flight_tracks.items():
await save_track_from_gpx_to_db(flight_id=id, gpx_filename=gpx_filename)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(migrate_gpx())
+49
View File
@@ -0,0 +1,49 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, '/app/src')
from background_jobs.flight_title_photo import add_circular_avatar # noqa
from database import models # noqa
from database.transaction import get_session # noqa
async def flight_title_photos():
async with get_session() as db:
flights = (await db.execute(
select(models.Photo, models.Flight, models.User)
.join(models.Flight.title_photo)
.join(models.Flight.created_by)
)).all()
for photo, flight, pilot in flights:
await add_circular_avatar(
base_image_path=f'/app/uploads/photos/{flight.id}/{photo.filename}.{photo.filename_extension}',
avatar_image_path=f'/app/uploads/profile/{pilot.id}/{pilot.avatar_image_filename}',
output_image_path=f'/app/uploads/photos/{flight.id}/title_photo.jpg',
)
async def user_title_photos():
async with get_session() as db:
users = (await db.execute(
select(models.User.id, models.User.title_image_filename, models.User.avatar_image_filename)
.filter(models.User.title_image_filename != '')
)).all()
for user_id, title_image, avatar_image in users:
await add_circular_avatar(
base_image_path=f"/app/uploads/profile/{user_id}/{title_image}",
avatar_image_path=f"/app/uploads/profile/{user_id}/{avatar_image}",
output_image_path=f'/app/uploads/profile/{user_id}/title_photo.jpg'
)
async def generate_images():
await user_title_photos()
await flight_title_photos()
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_images())
+31
View File
@@ -1,4 +1,7 @@
import os
import re
import uuid
from strawberry.file_uploads import Upload
def delete_file(path: str, silent: bool = False):
@@ -14,3 +17,31 @@ def check_directories(path: str):
os.makedirs(path, exist_ok=True)
except OSError:
pass
async def handle_file_upload(
file: Upload,
path: str,
filename_maxlength: int = 64,
uid_prefix: bool = True,
overwrite: bool = True
) -> str:
check_directories(path)
prefix = f"{uuid.uuid4()}-" if uid_prefix else ""
filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:]
# sanitize filename
filename = re.sub(r'[^\w_. -]', '', filename).replace(" ", "-")
target_path = f"{path}/{filename}"
if os.path.exists(target_path) and not overwrite:
raise FileExistsError(f"File {filename} already exists")
content = await file.read()
image = open(target_path, "wb")
image.write(content)
image.close()
return filename
+125
View File
@@ -0,0 +1,125 @@
import asyncio
from typing import Optional
from sqlalchemy import delete, func, select, text
from sqlalchemy import delete
from strawberry.file_uploads import Upload
from database import models
from database.transaction import get_session
from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import handle_file_upload, delete_file
def get_bounds(coordinates: list[dict[str, float]]) -> list[tuple[float, float]]:
latitudes = [c['lat'] for c in coordinates]
longitudes = [c['lng'] for c in coordinates]
return [
(min(latitudes), min(longitudes)),
(min(latitudes), max(longitudes)),
(max(latitudes), min(longitudes)),
(max(latitudes), max(latitudes))
]
async def get_airport_id_by_gps(gps_lat: float, gps_lng: float) -> Optional[int]:
async with get_session() as db:
query = (
select(models.Airport, func.coalesce(6371 * func.acos(
func.cos(func.radians(gps_lat)) *
func.cos(func.radians(models.Airport.gps_latitude)) *
func.cos(func.radians(models.Airport.gps_longitude) - func.radians(gps_lng)) +
func.sin(func.radians(gps_lat)) *
func.sin(func.radians(models.Airport.gps_latitude))
), 9999).label("distance"))
.filter(models.Airport.use_in_gpx_guess.is_(True))
.order_by("distance")
.having(text("distance < 1"))
.limit(1)
)
data = (await db.execute(query)).one_or_none()
if data:
airport, distance = data
return airport.id
return None
async def extract_basic_flight_info_from_gpx(gpx_filename: str) -> dict:
data = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
times, coordinates = await asyncio.gather(
data.get_times(),
data.get_coordinates()
)
takeoff_airport_id, landing_airport_id = await asyncio.gather(
get_airport_id_by_gps(coordinates[0]['lat'], coordinates[0]['lng']),
get_airport_id_by_gps(coordinates[-1]['lat'], coordinates[-1]['lng']),
)
return {
"takeoff_airport_id": takeoff_airport_id,
"landing_airport_id": landing_airport_id,
"takeoff_datetime": times[0],
"landing_datetime": times[-1],
}
async def handle_upload_gpx(gpx_track: Upload, flight_id: int):
filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH)
await save_track_from_gpx_to_db(gpx_filename=filename, flight_id=flight_id)
return filename
async def save_track_from_gpx_to_db(gpx_filename: str, flight_id: int | None = None):
try:
gpx_parser = GPXParser(file=f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
except OSError as e:
print(f"Cannot process {gpx_filename}: {e}")
return
altitudes = await gpx_parser.get_altitude()
terrain_elevations = await gpx_parser.get_terrain_elevation()
coordinates = await gpx_parser.get_coordinates()
speeds = await gpx_parser.get_speed()
magnetic_variations = await gpx_parser.get_magnetic_variation()
times = await gpx_parser.get_times()
track_data = {
"bounds": get_bounds(coordinates),
"min_speed": await gpx_parser.get_min_speed(),
"avg_speed": await gpx_parser.get_avg_speed(),
"max_speed": await gpx_parser.get_max_speed(),
"total_duration": (await gpx_parser.get_total_duration()).seconds,
"max_altitude": await gpx_parser.get_max_altitude(),
"avg_altitude": await gpx_parser.get_avg_altitude(),
}
async with get_session() as db:
flight = await models.Flight.get_one(db, id=flight_id)
if flight.track_id is None:
track = await models.Track.create(db, {
**track_data,
"created_by_id": flight.created_by_id
})
flight.track_id = track.id
else:
await db.execute(delete(models.TrackPoint).filter(models.TrackPoint.track_id == flight.track_id))
track = await models.Track.get_one(db, id=flight.track_id)
await models.Track.update(db, data=track_data, obj=track)
for i in range(len(coordinates)):
await models.TrackPoint.create(db, {
"track_id": track.id,
"altitude": altitudes[i] if i < len(altitudes) else None,
"magnetic_variation": magnetic_variations[i] if i < len(magnetic_variations) else None,
"terrain_elevation": terrain_elevations[i] if i < len(terrain_elevations) else None,
"speed": speeds[i] if i < len(speeds) else None,
"gps_latitude": coordinates[i]['lat'],
"gps_longitude": coordinates[i]['lng'],
"timestamp": times[i]
})
+57
View File
@@ -1,6 +1,63 @@
from typing import Tuple
import math
from geopy import Point
from geopy.distance import geodesic
EARTH_RADIUS = 6378.1 # Radius of the Earth
def get_bearing(center_point: tuple[float, float], end_point: tuple[float, float]):
lat3 = math.radians(end_point[0])
long3 = math.radians(end_point[1])
lat1 = math.radians(center_point[0])
long1 = math.radians(center_point[1])
dLon = long3 - long1
X = math.cos(lat3) * math.sin(dLon)
Y = math.cos(lat1) * math.sin(lat3) - math.sin(lat1) * math.cos(lat3) * math.cos(dLon)
end_brng = math.atan2(X, Y)
return end_brng
def get_distance(a: tuple[float, float], b: tuple[float, float]):
lat1 = math.radians(a[0])
lon1 = math.radians(a[1])
lat2 = math.radians(b[0])
lon2 = math.radians(b[1])
dlon = lon2 - lon1
dlat = lat2 - lat1
a = math.sin(dlat / 2) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return EARTH_RADIUS * c
def gps_to_decimal(input: Tuple[float, float, float]) -> float:
d, m, s = input
return d + (m / 60.0) + (s / 3600.0)
def generate_points(start: tuple[float, float], end: tuple[float, float], interval_meters: int = 500):
start = Point(start[0], start[1])
end = Point(end[0], end[1])
total_distance = geodesic(start, end).meters
num_intervals = int(total_distance // interval_meters)
points = [start]
for i in range(1, num_intervals + 1):
# Calculate intermediate point
intermediate_distance = interval_meters * i
intermediate_point = geodesic(meters=intermediate_distance).destination(start, get_bearing(start, end))
points.append(intermediate_point)
points.append(end)
return points
+21
View File
@@ -0,0 +1,21 @@
import typing
from itertools import islice
def chunks(items: list | dict, n: int) -> typing.Generator:
"""Yield successive n-sized chunks from lst."""
if isinstance(items, list):
for i in range(0, len(items), n):
yield items[i:i + n]
elif isinstance(items, dict):
it = iter(items)
for i in range(0, len(items), n):
yield {k: items[k] for k in islice(it, n)}
def evenly_spaced_elements(lst: list, count: int) -> list:
if count > len(lst):
return lst
interval = (len(lst) - 1) / (count - 1) if count > 1 else 0
return [lst[int(round(i * interval))] for i in range(count)]
-33
View File
@@ -1,33 +0,0 @@
import os
import re
import uuid
from strawberry.file_uploads import Upload
from utils.file import check_directories
async def handle_file_upload(
file: Upload,
path: str,
filename_maxlength: int = 64,
uid_prefix: bool = True,
overwrite: bool = True
) -> str:
check_directories(path)
prefix = f"{uuid.uuid4()}-" if uid_prefix else ""
filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:]
# sanitize filename
filename = re.sub(r'[^\w_. -]', '', filename).replace(" ", "-")
target_path = f"{path}/{filename}"
if os.path.exists(target_path) and not overwrite:
raise FileExistsError(f"File {filename} already exists")
content = await file.read()
image = open(target_path, "wb")
image.write(content)
image.close()
return filename
File diff suppressed because it is too large Load Diff