Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b0beb958 | ||
|
|
2e70f7bc2b | ||
|
|
94639da820 | ||
|
|
90eec72d0e | ||
|
|
9e6b0b574c | ||
|
|
10bc0847b6 | ||
|
|
e541508f75 | ||
|
|
ac3b496459 | ||
|
|
2bf737b9d6 | ||
|
|
ba5e73108f | ||
|
|
94613a5b1b |
@@ -10,3 +10,5 @@ uploads/*
|
|||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
src/emails/templates/html/*
|
src/emails/templates/html/*
|
||||||
.env
|
.env
|
||||||
|
/static/airports/
|
||||||
|
/static/airports/.gitkeep
|
||||||
|
|||||||
+7
-5
@@ -9,15 +9,17 @@ RUN mjml /app/templates/* -o /app/templates/html/
|
|||||||
|
|
||||||
FROM python:3.11-alpine AS app
|
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 && \
|
RUN apk update && \
|
||||||
apk add --no-cache tzdata mariadb-client mariadb-dev && \
|
apk add --no-cache tzdata mariadb-client mariadb-dev && \
|
||||||
pip install --upgrade pip
|
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 requirements.txt /app/requirements.txt
|
||||||
COPY --from=email_builder /app/templates/html/ /app/src/email/templates/html/
|
COPY --from=email_builder /app/templates/html/ /app/src/email/templates/html/
|
||||||
|
|||||||
+30
@@ -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 ###
|
||||||
+40
@@ -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 ###
|
||||||
+46
@@ -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 ###
|
||||||
+33
@@ -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 ###
|
||||||
@@ -12,3 +12,4 @@ services:
|
|||||||
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
|
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
|
||||||
volumes:
|
volumes:
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
|
- ./static:/app/static
|
||||||
|
|||||||
+14
-14
@@ -26,20 +26,20 @@ services:
|
|||||||
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000"
|
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000"
|
||||||
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
|
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
|
||||||
|
|
||||||
adminer:
|
# adminer:
|
||||||
image: adminer
|
# image: adminer
|
||||||
restart: always
|
# restart: always
|
||||||
ports:
|
# ports:
|
||||||
- 8080:8080
|
# - 8080:8080
|
||||||
|
#
|
||||||
scrapy:
|
# scrapy:
|
||||||
build:
|
# build:
|
||||||
context: ../crawlers
|
# context: ../crawlers
|
||||||
env_file:
|
# env_file:
|
||||||
- .env
|
# - .env
|
||||||
volumes:
|
# volumes:
|
||||||
- db-data:/var/lib/mysql:rw
|
# - db-data:/var/lib/mysql:rw
|
||||||
- ../crawlers:/app
|
# - ../crawlers:/app
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data: { }
|
db-data: { }
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ exif==1.6.0
|
|||||||
aiocache==0.12.2
|
aiocache==0.12.2
|
||||||
aiohttp==3.9.3
|
aiohttp==3.9.3
|
||||||
lxml==5.1.0
|
lxml==5.1.0
|
||||||
|
geopy==2.4.1
|
||||||
|
|||||||
@@ -1,40 +1,34 @@
|
|||||||
from aiohttp import ClientResponseError
|
from sqlalchemy import select
|
||||||
from database import models
|
from database import models
|
||||||
from database.transaction import get_session
|
from database.transaction import get_session
|
||||||
from external.elevation import elevation_api
|
from decorators.retryable import retryable
|
||||||
from external.gpx_parser import GPXParser
|
from external.elevation import elevation_api, update_track_points_elevation
|
||||||
from paths import FLIGHT_GPX_TRACK_PATH
|
|
||||||
|
|
||||||
|
|
||||||
async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
|
@retryable
|
||||||
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
|
async def add_terrain_elevation_to_flight(flight_id: int):
|
||||||
coordinates = await gpx_parser.get_coordinates_all()
|
async with get_session() as db:
|
||||||
|
track_points = (
|
||||||
try:
|
await db.scalars(
|
||||||
elevation = await elevation_api.get_elevation_for_points(coordinates)
|
select(models.TrackPoint)
|
||||||
tree_with_elevation = gpx_parser.add_terrain_elevation(elevation)
|
.join(models.TrackPoint.track)
|
||||||
output_name = f"terrain_{gpx_filename}"
|
.join(models.Track.flight)
|
||||||
gpx_parser.write(tree_with_elevation, f"{FLIGHT_GPX_TRACK_PATH}/{output_name}")
|
.filter(models.Flight.id == flight_id))
|
||||||
|
).all()
|
||||||
async with get_session() as db:
|
await update_track_points_elevation(db, track_points)
|
||||||
await models.Flight.update(
|
|
||||||
db, {"gpx_track_filename": output_name, "has_terrain_elevation": True},
|
|
||||||
id=flight_id
|
|
||||||
)
|
|
||||||
except ClientResponseError as e:
|
|
||||||
print(e)
|
|
||||||
|
|
||||||
|
|
||||||
|
@retryable
|
||||||
async def add_terrain_elevation_to_photo(photo):
|
async def add_terrain_elevation_to_photo(photo):
|
||||||
try:
|
try:
|
||||||
elevation = await elevation_api.get_elevation_for_points([
|
elevation = await elevation_api.get_elevation_for_points(
|
||||||
{"lat": photo.gps_latitude, "lng": photo.gps_longitude}
|
[{"lat": photo.gps_latitude, "lng": photo.gps_longitude}]
|
||||||
])
|
)
|
||||||
if not elevation:
|
if not elevation:
|
||||||
print("Cannot get elevation")
|
print("Cannot get elevation")
|
||||||
return
|
return
|
||||||
|
|
||||||
terrain_elevation = elevation[0]['elevation']
|
terrain_elevation = elevation[0].elevation
|
||||||
async with get_session() as db:
|
async with get_session() as db:
|
||||||
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
|
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -6,19 +6,14 @@ from external.weather import weather_api
|
|||||||
from logger import log
|
from logger import log
|
||||||
|
|
||||||
|
|
||||||
async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
|
async def get_weather(date_time: datetime, gps: tuple[float, float]):
|
||||||
async with get_session() as db:
|
|
||||||
airport = await models.Airport.get_one(db, id=airport_id)
|
|
||||||
gps = (airport.gps_latitude, airport.gps_longitude)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps)
|
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps)
|
||||||
log.warning(weather)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.error(f"Error in downloading weather: {e}")
|
log.error(f"Error in downloading weather: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
data = {
|
return {
|
||||||
"datetime": weather['datetime'],
|
"datetime": weather['datetime'],
|
||||||
"qnh": weather['pressure_msl'],
|
"qnh": weather['pressure_msl'],
|
||||||
"temperature_surface": weather['temperature_2m'],
|
"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'],
|
"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:
|
async with get_session() as db:
|
||||||
flight = await models.Flight.get_one(db, id=flight_id)
|
flight = await models.Flight.get_one(db, id=flight_id)
|
||||||
existing_weather_id = getattr(flight, f'{type_}_weather_info_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:
|
else:
|
||||||
weather_model = await models.WeatherInfo.create(db, data=data)
|
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})
|
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
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
APP_DEBUG = True
|
APP_DEBUG = os.environ.get("APP_ENV", 'production') == 'development'
|
||||||
GRAPHIQL = True
|
GRAPHIQL = True
|
||||||
|
|
||||||
REFRESH_TOKEN_VALIDITY_DAYS = 30
|
REFRESH_TOKEN_VALIDITY_DAYS = 30
|
||||||
|
|||||||
+53
-26
@@ -74,7 +74,6 @@ copilot_has_photo = Table(
|
|||||||
Column("photo_id", ForeignKey("photo.id"), primary_key=True),
|
Column("photo_id", ForeignKey("photo.id"), primary_key=True),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
airport_has_reporting_point = Table(
|
airport_has_reporting_point = Table(
|
||||||
"airport_has_reporting_point",
|
"airport_has_reporting_point",
|
||||||
BaseModel.metadata,
|
BaseModel.metadata,
|
||||||
@@ -105,6 +104,8 @@ class FlightPlan(BaseModel):
|
|||||||
is_default_name: Mapped[bool] = mapped_column(Boolean, server_default='0')
|
is_default_name: Mapped[bool] = mapped_column(Boolean, server_default='0')
|
||||||
is_public: 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')
|
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_speed: Mapped[int] = mapped_column(Integer, nullable=False, default=150)
|
||||||
planned_takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
planned_takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=True)
|
||||||
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), 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)
|
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_plan_has_copilot)
|
||||||
aircraft: Mapped['Aircraft'] = relationship()
|
aircraft: Mapped['Aircraft'] = relationship()
|
||||||
markers: Mapped['FlightPlanMarker'] = relationship()
|
markers: Mapped[List['FlightPlanMarker']] = relationship()
|
||||||
created_by: Mapped['User'] = 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):
|
class FlightPlanMarker(BaseModel):
|
||||||
__tablename__ = "flight_plan_marker"
|
__tablename__ = "flight_plan_marker"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
flight_plan_id: Mapped[int] = mapped_column(Integer, ForeignKey('flight_plan.id'))
|
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)
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
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)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
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)
|
icao_code: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||||
gps_longitude: 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_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
|
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()
|
created_by: Mapped['User'] = relationship()
|
||||||
|
|
||||||
|
|
||||||
@@ -286,22 +327,6 @@ class Aircraft(BaseModel):
|
|||||||
organization: Mapped['Organization'] = relationship()
|
organization: Mapped['Organization'] = relationship()
|
||||||
flights: Mapped[Set['Flight']] = relationship()
|
flights: Mapped[Set['Flight']] = relationship()
|
||||||
created_by: Mapped['User'] = 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):
|
class Organization(BaseModel):
|
||||||
@@ -317,15 +342,15 @@ class Organization(BaseModel):
|
|||||||
created_by: Mapped['User'] = relationship()
|
created_by: Mapped['User'] = relationship()
|
||||||
|
|
||||||
|
|
||||||
class FlightTrack(BaseModel):
|
class FlightTurnPoint(BaseModel):
|
||||||
__tablename__ = "flight_track"
|
__tablename__ = "flight_turn_point"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False)
|
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)
|
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)
|
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
|
||||||
landing_duration: Mapped[int] = mapped_column(Integer, 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()
|
flight: Mapped['Flight'] = relationship()
|
||||||
point_of_interest: Mapped['PointOfInterest'] = relationship()
|
point_of_interest: Mapped['PointOfInterest'] = relationship()
|
||||||
@@ -382,8 +407,10 @@ class Flight(BaseModel):
|
|||||||
landing_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_total: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||||
duration_pic: 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)
|
flight_plan_id: Mapped[int] = mapped_column(Integer, ForeignKey('flight_plan.id'), nullable=True)
|
||||||
has_terrain_elevation: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
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'))
|
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)
|
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)
|
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])
|
landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id])
|
||||||
weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[landing_weather_info_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])
|
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()
|
event: Mapped['Event'] = relationship()
|
||||||
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_has_copilot)
|
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_has_copilot)
|
||||||
aircraft: Mapped['Aircraft'] = relationship()
|
aircraft: Mapped['Aircraft'] = relationship()
|
||||||
photos: Mapped[List['Photo']] = relationship(foreign_keys=[Photo.flight_id])
|
photos: Mapped[List['Photo']] = relationship(foreign_keys=[Photo.flight_id])
|
||||||
created_by: Mapped['User'] = relationship()
|
created_by: Mapped['User'] = relationship()
|
||||||
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
|
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
|
||||||
|
track: Mapped['Track'] = relationship()
|
||||||
|
|
||||||
class Copilot(BaseModel):
|
class Copilot(BaseModel):
|
||||||
__tablename__ = "copilot"
|
__tablename__ = "copilot"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -11,19 +11,18 @@ from graphql_schema.schema import GraphQLContext, schema
|
|||||||
|
|
||||||
|
|
||||||
def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
|
def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
|
||||||
if not APP_DEBUG:
|
if APP_DEBUG:
|
||||||
return
|
debug_router = APIRouter()
|
||||||
debug_router = APIRouter()
|
|
||||||
|
|
||||||
@debug_router.get("/graphql/autologin")
|
@debug_router.get("/graphql/autologin")
|
||||||
async def autologin():
|
async def autologin():
|
||||||
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
|
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
|
||||||
response = RedirectResponse(url="/graphql")
|
response = RedirectResponse(url="/graphql")
|
||||||
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14))
|
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14))
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
app.include_router(debug_router)
|
app.include_router(debug_router)
|
||||||
|
|
||||||
async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)):
|
async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)):
|
||||||
user_id = credentials['id'] if credentials else None
|
user_id = credentials['id'] if credentials else None
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class LoginEndpoint(AuthEndpoint):
|
|||||||
access_token = self.access_security.create_access_token(subject=subject)
|
access_token = self.access_security.create_access_token(subject=subject)
|
||||||
refresh_token = self.refresh_security.create_refresh_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.access_security.set_access_cookie(resp, access_token)
|
||||||
self.refresh_security.set_refresh_cookie(
|
self.refresh_security.set_refresh_cookie(
|
||||||
resp, refresh_token,
|
resp, refresh_token,
|
||||||
|
|||||||
Vendored
+73
-13
@@ -1,27 +1,87 @@
|
|||||||
from typing import List, Dict
|
import dataclasses
|
||||||
|
from typing import Optional
|
||||||
import aiohttp
|
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:
|
class ElevationAPI:
|
||||||
ELEVATION_ENDPOINT = "https://api.open-elevation.com/api/v1/lookup"
|
ELEVATION_ENDPOINT: str
|
||||||
|
|
||||||
def get_request(self, points: List[Dict[str, float]]):
|
def get_request(self, session: aiohttp.ClientSession, points: list[dict[str, float]]):
|
||||||
return {"locations": [{"latitude": point['lat'], "longitude": point['lng']} for point in points]}
|
...
|
||||||
|
|
||||||
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 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()
|
resp.raise_for_status()
|
||||||
return await resp.json()
|
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)
|
response = await self.call_api(points)
|
||||||
|
|
||||||
return [{
|
elevation = []
|
||||||
"lat": loc['latitude'],
|
for i, point in enumerate(points):
|
||||||
"lng": loc['longitude'],
|
response_point = response['results'][i]
|
||||||
"elevation": loc['elevation']
|
elevation.append(ElevationInfo(**point, elevation=response_point['elevation']))
|
||||||
} for loc in response['results']]
|
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()
|
||||||
|
|||||||
Vendored
-34
@@ -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
|
|
||||||
Vendored
+8
-42
@@ -1,9 +1,7 @@
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from aiocache import cached
|
|
||||||
from lxml import etree
|
from lxml import etree
|
||||||
from lxml.etree import _ElementTree
|
|
||||||
|
|
||||||
|
|
||||||
class GPXParser:
|
class GPXParser:
|
||||||
@@ -68,50 +66,47 @@ class GPXParser:
|
|||||||
def run_xpath(self, path: str):
|
def run_xpath(self, path: str):
|
||||||
return self.gpx.xpath(path, namespaces=self.namespace)
|
return self.gpx.xpath(path, namespaces=self.namespace)
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_times_all(self):
|
async def get_times_all(self):
|
||||||
nodes = self.run_xpath("//gpx:trkpt/gpx:time")
|
nodes = self.run_xpath("//gpx:trkpt/gpx:time")
|
||||||
return [datetime.fromisoformat(node.text).astimezone() for node in nodes]
|
return [datetime.fromisoformat(node.text).astimezone() for node in nodes]
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_times(self):
|
async def get_times(self):
|
||||||
times = await self.get_times_all()
|
times = await self.get_times_all()
|
||||||
return await self.sample_times(times)
|
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]]:
|
async def get_coordinates(self) -> List[Dict[str, float]]:
|
||||||
return await self.average_coordinates(await self.get_coordinates_all())
|
return await self.average_coordinates(await self.get_coordinates_all())
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_coordinates_all(self) -> List[Dict[str, float]]:
|
async def get_coordinates_all(self) -> List[Dict[str, float]]:
|
||||||
nodes = self.run_xpath("//gpx:trkpt")
|
nodes = self.run_xpath("//gpx:trkpt")
|
||||||
return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
|
return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_speed(self) -> List[float]:
|
async def get_speed(self) -> List[float]:
|
||||||
nodes = self.run_xpath("//gpx:speed")
|
nodes = self.run_xpath("//gpx:speed")
|
||||||
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_magnetic_variation(self) -> List[float]:
|
async def get_magnetic_variation(self) -> List[float]:
|
||||||
nodes = self.run_xpath("//gpx:magvar")
|
nodes = self.run_xpath("//gpx:magvar")
|
||||||
return await self.average_sample_numbers([int(node.text) for node in nodes])
|
return await self.average_sample_numbers([int(node.text) for node in nodes])
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_altitude(self) -> List[float]:
|
async def get_altitude(self) -> List[float]:
|
||||||
nodes = self.run_xpath("//gpx:ele")
|
nodes = self.run_xpath("//gpx:ele")
|
||||||
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_terrain_elevation(self) -> List[float]:
|
async def get_terrain_elevation(self) -> List[float]:
|
||||||
nodes = self.run_xpath("//gpx:terrain_elevation")
|
nodes = self.run_xpath("//gpx:terrain_elevation")
|
||||||
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
return await self.average_sample_numbers([float(node.text) for node in nodes])
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_max_speed(self):
|
async def get_max_speed(self):
|
||||||
return max(await self.get_speed()) or 0
|
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):
|
async def get_avg_speed(self):
|
||||||
speeds = await self.get_speed()
|
speeds = await self.get_speed()
|
||||||
if not speeds:
|
if not speeds:
|
||||||
@@ -119,38 +114,9 @@ class GPXParser:
|
|||||||
|
|
||||||
return round(sum(speeds) / len(speeds), 2)
|
return round(sum(speeds) / len(speeds), 2)
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_max_altitude(self):
|
async def get_max_altitude(self):
|
||||||
return max(await self.get_altitude()) or 0
|
return max(await self.get_altitude()) or 0
|
||||||
|
|
||||||
@cached()
|
|
||||||
async def get_avg_altitude(self):
|
async def get_avg_altitude(self):
|
||||||
altitudes = await self.get_altitude()
|
altitudes = await self.get_altitude()
|
||||||
return round(sum(altitudes) / len(altitudes), 2)
|
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)
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ import aiohttp
|
|||||||
from sqlalchemy import delete
|
from sqlalchemy import delete
|
||||||
from database import models
|
from database import models
|
||||||
from database.transaction import get_session
|
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
|
# https://pyopenair.readthedocs.io/en/latest/openair.html
|
||||||
|
|||||||
Vendored
+2
-1
@@ -3,6 +3,7 @@ import urllib.parse
|
|||||||
from typing import Tuple, Dict
|
from typing import Tuple, Dict
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from aiocache import cached
|
from aiocache import cached
|
||||||
|
from decorators.retryable import retryable
|
||||||
|
|
||||||
|
|
||||||
class Weather:
|
class Weather:
|
||||||
@@ -38,6 +39,7 @@ class Weather:
|
|||||||
return f"{url}{query_string}"
|
return f"{url}{query_string}"
|
||||||
|
|
||||||
@cached(ttl=6 * 3600)
|
@cached(ttl=6 * 3600)
|
||||||
|
@retryable
|
||||||
async def download_weather_for_day(self, date: datetime.date, gps: Tuple[float, float]):
|
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)
|
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)
|
data = await self.download_weather_for_day(date_time.date(), gps)
|
||||||
|
|
||||||
# TODO: kontrola timezone!
|
# TODO: kontrola timezone!
|
||||||
# TODO: interpolace - udelat vazenyprumer z dvou po sobe jdoucich hodin
|
|
||||||
idx = date_time.hour
|
idx = date_time.hour
|
||||||
result_data = {metric: data['hourly'][metric][idx] for metric in self.METRICS}
|
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")
|
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(
|
select(
|
||||||
models.Flight.id,
|
models.Flight.id,
|
||||||
func.timediff(models.Flight.landing_datetime, models.Flight.takeoff_datetime).label("diff"),
|
func.timediff(models.Flight.landing_datetime, models.Flight.takeoff_datetime).label("diff"),
|
||||||
func.coalesce(func.sum(models.FlightTrack.landing_duration), 0).label("landing_duration")
|
func.coalesce(func.sum(models.FlightTurnPoint.landing_duration), 0).label("landing_duration")
|
||||||
).join(models.Flight.track, isouter=True)
|
).join(models.Flight.turn_points, isouter=True)
|
||||||
.group_by(models.Flight.id)
|
.group_by(models.Flight.id)
|
||||||
.filter(models.Flight.id.in_(ids))
|
.filter(models.Flight.id.in_(ids))
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,24 @@ reporting_points_dataloader = DataLoader(
|
|||||||
).load, cache=False
|
).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(
|
flights_by_copilot_dataloader = DataLoader(
|
||||||
load_fn=MultiModelsDataloader(
|
load_fn=MultiModelsDataloader(
|
||||||
models.Flight,
|
models.Flight,
|
||||||
@@ -80,7 +98,7 @@ flight_by_poi_dataloader = DataLoader(
|
|||||||
models.Flight,
|
models.Flight,
|
||||||
relationship_column=models.PointOfInterest.id,
|
relationship_column=models.PointOfInterest.id,
|
||||||
order_by=[models.Flight.takeoff_datetime.desc()],
|
order_by=[models.Flight.takeoff_datetime.desc()],
|
||||||
extra_join=[models.Flight.track, models.PointOfInterest]
|
extra_join=[models.Flight.turn_points, models.PointOfInterest]
|
||||||
).load,
|
).load,
|
||||||
cache=False
|
cache=False
|
||||||
)
|
)
|
||||||
@@ -137,11 +155,20 @@ poi_photos_dataloader = DataLoader(
|
|||||||
).load,
|
).load,
|
||||||
cache=False
|
cache=False
|
||||||
)
|
)
|
||||||
flight_track_dataloader = DataLoader(
|
flight_turn_points_dataloader = DataLoader(
|
||||||
load_fn=MultiModelsDataloader(
|
load_fn=MultiModelsDataloader(
|
||||||
models.FlightTrack,
|
models.FlightTurnPoint,
|
||||||
relationship_column=models.FlightTrack.flight_id,
|
relationship_column=models.FlightTurnPoint.flight_id,
|
||||||
order_by=[models.FlightTrack.order]
|
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,
|
).load,
|
||||||
cache=False
|
cache=False
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ aircraft_dataloader = create_dataloader(models.Aircraft)
|
|||||||
event_dataloader = create_dataloader(models.Event)
|
event_dataloader = create_dataloader(models.Event)
|
||||||
organizations_dataloader = create_dataloader(models.Organization)
|
organizations_dataloader = create_dataloader(models.Organization)
|
||||||
airport_weather_info_loader = create_dataloader(models.WeatherInfo)
|
airport_weather_info_loader = create_dataloader(models.WeatherInfo)
|
||||||
|
track_dataloader = create_dataloader(models.Track)
|
||||||
poi_dataloader = create_dataloader(models.PointOfInterest)
|
poi_dataloader = create_dataloader(models.PointOfInterest)
|
||||||
poi_type_dataloader = create_dataloader(models.PointOfInterestType)
|
poi_type_dataloader = create_dataloader(models.PointOfInterestType)
|
||||||
flight_dataloader = create_dataloader(models.Flight)
|
flight_dataloader = create_dataloader(models.Flight)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from graphql_schema.entities.types.types import Airport
|
|||||||
class AirportQueries:
|
class AirportQueries:
|
||||||
@strawberry.field()
|
@strawberry.field()
|
||||||
@error_logging
|
@error_logging
|
||||||
@authenticated_user_only()
|
# @authenticated_user_only()
|
||||||
async def airports(root, info) -> List[Airport]:
|
async def airports(root, info) -> List[Airport]:
|
||||||
return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id)
|
return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import strawberry
|
import strawberry
|
||||||
from graphql import GraphQLError
|
|
||||||
|
|
||||||
from decorators.endpoints import authenticated_user_only, allow_public
|
from decorators.endpoints import authenticated_user_only, allow_public
|
||||||
from decorators.error_logging import error_logging
|
from decorators.error_logging import error_logging
|
||||||
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
|
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from typing import List
|
from typing import List, Optional
|
||||||
import strawberry
|
import strawberry
|
||||||
from strawberry.types import Info
|
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 decorators.endpoints import authenticated_user_only, allow_public
|
||||||
from graphql_schema.entities.resolvers.base import BaseQueryResolver
|
from decorators.error_logging import error_logging
|
||||||
from graphql_schema.entities.resolvers.flight_plan import FlightPlanMutationResolver
|
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.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
|
||||||
from graphql_schema.entities.types.types import FlightPlan
|
from graphql_schema.entities.types.types import FlightPlan
|
||||||
|
|
||||||
@@ -16,15 +15,26 @@ class FlightPlanQueries:
|
|||||||
@error_logging
|
@error_logging
|
||||||
@authenticated_user_only()
|
@authenticated_user_only()
|
||||||
async def flight_plans(root, info: Info) -> List[FlightPlan]:
|
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()
|
@strawberry.field()
|
||||||
@error_logging
|
@error_logging
|
||||||
@allow_public
|
@allow_public
|
||||||
async def flight_plan(root, info: Info, id: int) -> FlightPlan:
|
async def flight_plan(
|
||||||
return await BaseQueryResolver(FlightPlan, models.FlightPlan).get_one(
|
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,
|
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
|
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 = {}
|
filter_params = {}
|
||||||
if id:
|
if id:
|
||||||
filter_params['object_id'] = id
|
filter_params['object_id'] = id
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import List, Optional
|
import random
|
||||||
from sqlalchemy import delete, insert, select, func, text
|
from typing import Optional
|
||||||
|
from sqlalchemy import delete, insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.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 import models
|
||||||
from database.models import flight_has_copilot
|
from database.models import flight_has_copilot
|
||||||
from database.transaction import get_session
|
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.helpers.combobox import handle_combobox_save
|
||||||
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
|
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
|
||||||
from graphql_schema.entities.types.mutation_input import (
|
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 graphql_schema.entities.types.types import Flight
|
||||||
from paths import FLIGHT_GPX_TRACK_PATH
|
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.str_utils import random_str
|
||||||
from utils.upload import handle_file_upload
|
|
||||||
|
|
||||||
|
|
||||||
class FlightQueryResolver(BaseQueryResolver):
|
class FlightQueryResolver(BaseQueryResolver):
|
||||||
@@ -28,17 +27,11 @@ class FlightQueryResolver(BaseQueryResolver):
|
|||||||
def get_query(
|
def get_query(
|
||||||
self,
|
self,
|
||||||
user_id: Optional[int] = None,
|
user_id: Optional[int] = None,
|
||||||
object_id: Optional[int] = None,
|
|
||||||
url_slug: Optional[str] = None,
|
|
||||||
only_public: Optional[bool] = False,
|
only_public: Optional[bool] = False,
|
||||||
*args,
|
*args,
|
||||||
**kwargs
|
**kwargs
|
||||||
):
|
):
|
||||||
filters = {}
|
filters = {key: kwargs[key] for key in ("object_id", "url_slug") if key in kwargs}
|
||||||
if object_id:
|
|
||||||
filters['object_id'] = object_id
|
|
||||||
if url_slug:
|
|
||||||
filters['url_slug'] = url_slug
|
|
||||||
|
|
||||||
query = super().get_query(
|
query = super().get_query(
|
||||||
user_id,
|
user_id,
|
||||||
@@ -62,8 +55,8 @@ class FlightQueryResolver(BaseQueryResolver):
|
|||||||
|
|
||||||
if kwargs.get("point_of_interest_id"):
|
if kwargs.get("point_of_interest_id"):
|
||||||
query = (
|
query = (
|
||||||
query.join(models.Flight.track)
|
query.join(models.Flight.turn_points)
|
||||||
.filter(models.FlightTrack.point_of_interest_id == kwargs["point_of_interest_id"])
|
.filter(models.FlightTurnPoint.point_of_interest_id == kwargs["point_of_interest_id"])
|
||||||
)
|
)
|
||||||
|
|
||||||
if kwargs.get('username'):
|
if kwargs.get('username'):
|
||||||
@@ -79,55 +72,13 @@ class FlightMutationResolver(BaseMutationResolver):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(Flight, models.Flight)
|
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:
|
async def create(self, context, input: CreateFlightInput) -> Flight:
|
||||||
data = input.to_dict()
|
data = input.to_dict()
|
||||||
user_id = context.user_id
|
user_id = context.user_id
|
||||||
|
|
||||||
if input.gpx_track_file:
|
if input.gpx_track_file:
|
||||||
data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, context=context)
|
data['gpx_track_filename'] = await handle_file_upload(input.gpx_track_file, FLIGHT_GPX_TRACK_PATH)
|
||||||
data_from_gpx = await self.extract_data_from_gpx(data['gpx_track_filename'])
|
data_from_gpx = await extract_basic_flight_info_from_gpx(data['gpx_track_filename'])
|
||||||
data.update(data_from_gpx)
|
data.update(data_from_gpx)
|
||||||
else:
|
else:
|
||||||
async with get_session() as db:
|
async with get_session() as db:
|
||||||
@@ -148,22 +99,19 @@ class FlightMutationResolver(BaseMutationResolver):
|
|||||||
data.update({
|
data.update({
|
||||||
"aircraft_id": aircraft_id,
|
"aircraft_id": aircraft_id,
|
||||||
"has_terrain_elevation": False,
|
"has_terrain_elevation": False,
|
||||||
"name": "",
|
"name": input.name or '',
|
||||||
"description": "",
|
"description": "",
|
||||||
"created_by_id": context.user_id
|
"created_by_id": context.user_id
|
||||||
})
|
})
|
||||||
flight = await self._do_create(db, data)
|
flight = await self._do_create(db, data)
|
||||||
|
|
||||||
context.background_tasks.add_task(
|
if input.track is not None:
|
||||||
download_weather,
|
await handle_track_edit(db=db, flight_id=flight.id, track=input.track, user_id=user_id)
|
||||||
flight_id=flight.id, airport_id=flight.takeoff_airport_id, date_time=flight.takeoff_datetime,
|
|
||||||
type_="takeoff"
|
if data['gpx_track_filename']:
|
||||||
)
|
await save_track_from_gpx_to_db(gpx_filename=data['gpx_track_filename'], flight_id=flight.id)
|
||||||
context.background_tasks.add_task(
|
|
||||||
download_weather,
|
schedule_background_tasks(flight.id, data, context)
|
||||||
flight_id=flight.id, airport_id=flight.landing_airport_id, date_time=flight.landing_datetime,
|
|
||||||
type_="landing"
|
|
||||||
)
|
|
||||||
|
|
||||||
return flight
|
return flight
|
||||||
|
|
||||||
@@ -177,40 +125,23 @@ class FlightMutationResolver(BaseMutationResolver):
|
|||||||
data = input.to_dict()
|
data = input.to_dict()
|
||||||
|
|
||||||
if input.gpx_track_file is not None:
|
if input.gpx_track_file is not None:
|
||||||
data['gpx_track_filename'] = await handle_upload_gpx(
|
data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, flight_id=flight_id)
|
||||||
gpx_track=input.gpx_track_file,
|
|
||||||
context=context,
|
|
||||||
original_gpx_filename=flight_data['gpx_track_filename']
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_session() as db:
|
async with get_session() as db:
|
||||||
if input.takeoff_airport:
|
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",
|
db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code",
|
||||||
extra_data={"name": input.takeoff_airport.name}
|
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']
|
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:
|
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",
|
db, models.Airport, input.landing_airport, user_id, name_column="icao_code",
|
||||||
extra_data={"name": input.landing_airport.name}
|
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']
|
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:
|
if input.aircraft is not None:
|
||||||
data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft)
|
data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft)
|
||||||
|
|
||||||
@@ -239,21 +170,27 @@ class FlightMutationResolver(BaseMutationResolver):
|
|||||||
token=random_str(64)
|
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):
|
def schedule_background_tasks(flight_id: int, flight_data: dict, context) -> None:
|
||||||
if original_gpx_filename:
|
context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=flight_id)
|
||||||
delete_file(FLIGHT_GPX_TRACK_PATH + "/" + original_gpx_filename, silent=True)
|
context.background_tasks.add_task(
|
||||||
|
download_weather_for_flight, flight_id=id, airport_id=flight_data['takeoff_airport_id'],
|
||||||
filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH)
|
date_time=flight_data['takeoff_datetime'], type_="takeoff"
|
||||||
context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=id, gpx_filename=filename)
|
)
|
||||||
|
context.background_tasks.add_task(
|
||||||
return filename
|
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):
|
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))
|
await db.execute(delete(models.FlightTurnPoint).filter(models.FlightTurnPoint.flight_id == flight_id))
|
||||||
|
|
||||||
order = 0
|
order = 0
|
||||||
for item in track:
|
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:
|
if item.point_of_interest:
|
||||||
poi_id = await handle_combobox_save(
|
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:
|
if item.airport:
|
||||||
airport_id = await handle_combobox_save(
|
airport_id = await handle_combobox_save(
|
||||||
db, models.Airport, item.airport, user_id,
|
db, models.Airport, item.airport, user_id,
|
||||||
name_column="icao_code",
|
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,
|
db,
|
||||||
data={
|
data={
|
||||||
"flight_id": flight_id,
|
"flight_id": flight_id,
|
||||||
|
|||||||
@@ -1,19 +1,51 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from typing import Optional
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.dialects.mysql import insert
|
from sqlalchemy.dialects.mysql import insert
|
||||||
|
|
||||||
|
from background_jobs.flight_plan_track import generate_track_for_flight_plan
|
||||||
from database import models
|
from database import models
|
||||||
from database.models import flight_plan_has_copilot
|
from database.models import flight_plan_has_copilot
|
||||||
from database.transaction import get_session
|
from database.transaction import get_session
|
||||||
from graphql_schema.entities.helpers.combobox import handle_combobox_save
|
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.resolvers.flight import handle_aircraft_save
|
||||||
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
|
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
|
||||||
from graphql_schema.entities.types.types import FlightPlan
|
from graphql_schema.entities.types.types import FlightPlan
|
||||||
|
from utils.list_utils import evenly_spaced_elements
|
||||||
from utils.str_utils import random_str
|
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):
|
class FlightPlanMutationResolver(BaseMutationResolver):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
|
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
|
||||||
@@ -23,9 +55,17 @@ class FlightPlanMutationResolver(BaseMutationResolver):
|
|||||||
position = 0
|
position = 0
|
||||||
|
|
||||||
for marker in markers:
|
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={
|
await models.FlightPlanMarker.create(db, data={
|
||||||
"position": position,
|
"position": position,
|
||||||
"flight_plan_id": flight_plan.id,
|
"flight_plan_id": flight_plan.id,
|
||||||
|
"airport_id": marker.airport_id,
|
||||||
|
"point_of_interest_id": marker.point_of_interest_id,
|
||||||
"type": marker.type,
|
"type": marker.type,
|
||||||
"name": marker.name,
|
"name": marker.name,
|
||||||
"gps_latitude": marker.gps_latitude,
|
"gps_latitude": marker.gps_latitude,
|
||||||
@@ -79,7 +119,10 @@ class FlightPlanMutationResolver(BaseMutationResolver):
|
|||||||
if data.markers:
|
if data.markers:
|
||||||
markers = data.markers
|
markers = data.markers
|
||||||
else:
|
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)
|
used_markers = evenly_spaced_elements(markers, 5)
|
||||||
input_data['name'] = " - ".join(m.name for m in used_markers)
|
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)
|
flight_plan = await self._do_update(db, obj=flight_plan_model, data=input_data)
|
||||||
return flight_plan
|
|
||||||
|
|
||||||
|
await generate_track_for_flight_plan(flight_plan.id)
|
||||||
|
|
||||||
def evenly_spaced_elements(lst: list, count: int) -> list:
|
return flight_plan
|
||||||
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 paths import get_photo_basepath
|
||||||
from utils.file import delete_file
|
from utils.file import delete_file
|
||||||
from utils.image import PhotoEditor, parse_exif_info
|
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):
|
class PhotoQueryResolver(BaseQueryResolver):
|
||||||
|
|||||||
@@ -138,11 +138,14 @@ class EditFlightInput(BaseGraphqlInputType):
|
|||||||
@strawberry.input()
|
@strawberry.input()
|
||||||
class CreateFlightInput(BaseGraphqlInputType):
|
class CreateFlightInput(BaseGraphqlInputType):
|
||||||
aircraft: ComboboxInput
|
aircraft: ComboboxInput
|
||||||
|
flight_plan_id: Optional[int] = None
|
||||||
|
name: Optional[str] = None
|
||||||
gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
|
gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
|
||||||
landing_airport: Optional[ComboboxInput] = None
|
landing_airport: Optional[ComboboxInput] = None
|
||||||
takeoff_airport: Optional[ComboboxInput] = None
|
takeoff_airport: Optional[ComboboxInput] = None
|
||||||
takeoff_datetime: Optional[datetime] = None
|
takeoff_datetime: Optional[datetime] = None
|
||||||
landing_datetime: Optional[datetime] = None
|
landing_datetime: Optional[datetime] = None
|
||||||
|
track: Optional[List[TrackItemInput]] = None
|
||||||
|
|
||||||
|
|
||||||
@strawberry.input()
|
@strawberry.input()
|
||||||
@@ -150,6 +153,8 @@ class TrackItemInput:
|
|||||||
point_of_interest: Optional[ComboboxInput] = None
|
point_of_interest: Optional[ComboboxInput] = None
|
||||||
airport: Optional[ComboboxInput] = None
|
airport: Optional[ComboboxInput] = None
|
||||||
landing_duration: Optional[int] = 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'])
|
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename'])
|
||||||
|
|||||||
@@ -1,30 +1,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
from datetime import datetime
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import strawberry
|
import strawberry
|
||||||
from database import models
|
from database import models
|
||||||
from decorators.endpoints import authenticated_user_only
|
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 external.gpx_parser import GPXParser
|
||||||
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
|
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
|
||||||
from graphql_schema.dataloaders.multi_models import (
|
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,
|
photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader,
|
||||||
aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_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,
|
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,
|
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 (
|
from graphql_schema.dataloaders.single_model import (
|
||||||
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader,
|
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader,
|
||||||
airport_weather_info_loader, organizations_dataloader, flight_dataloader, photo_adjustment_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.permissions import IsAuthenticated
|
||||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
|
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
|
@strawberry.type
|
||||||
@@ -33,18 +31,6 @@ class Point:
|
|||||||
lng: float
|
lng: float
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
|
||||||
class GPXTrack:
|
|
||||||
coordinates: List[Point]
|
|
||||||
speed: List[float]
|
|
||||||
altitude: List[float]
|
|
||||||
magnetic_variation: List[float]
|
|
||||||
terrain_elevation: List[float]
|
|
||||||
time: List[datetime]
|
|
||||||
max_speed: float
|
|
||||||
avg_speed: float
|
|
||||||
max_altitude: float
|
|
||||||
avg_altitude: float
|
|
||||||
|
|
||||||
|
|
||||||
@strawberry_sqlalchemy_type(models.ReportingPoint)
|
@strawberry_sqlalchemy_type(models.ReportingPoint)
|
||||||
@@ -52,14 +38,24 @@ class ReportingPoint:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry_sqlalchemy_type(models.Runway)
|
||||||
|
class Runway:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@strawberry_sqlalchemy_type(models.Frequency)
|
||||||
|
class Frequency:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@strawberry_sqlalchemy_type(models.Airport)
|
@strawberry_sqlalchemy_type(models.Airport)
|
||||||
class Airport:
|
class Airport:
|
||||||
reporting_points: List[ReportingPoint] = strawberry.field(
|
reporting_points: List[ReportingPoint] = strawberry.field(
|
||||||
resolver=lambda root: reporting_points_dataloader.load(root.id)
|
resolver=lambda root: reporting_points_dataloader.load(root.id)
|
||||||
)
|
)
|
||||||
|
runways: List[Runway] = strawberry.field(resolver=lambda root: runways_dataloader.load(root.id))
|
||||||
# TODO: loader na ikonu
|
frequencies: List[Frequency] = strawberry.field(resolver=lambda root: frequencies_dataloader.load(root.id))
|
||||||
# TODO: loader na RWY a frekvence
|
icon_url: str = strawberry.field(resolver=lambda root: airport_icon_url(root.icao_code))
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
@@ -78,8 +74,8 @@ class Airspace:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@strawberry_sqlalchemy_type(models.FlightTrack)
|
@strawberry_sqlalchemy_type(models.FlightTurnPoint)
|
||||||
class FlightTrack:
|
class FlightTurnPoint:
|
||||||
point_of_interest: Optional[PointOfInterest] = strawberry.field(
|
point_of_interest: Optional[PointOfInterest] = strawberry.field(
|
||||||
resolver=lambda root: poi_dataloader.load(root.point_of_interest_id)
|
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)
|
@strawberry_sqlalchemy_type(models.Flight)
|
||||||
class Flight:
|
class Flight:
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
@@ -131,28 +141,6 @@ class Flight:
|
|||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
setattr(self, key, value)
|
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=[])
|
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
|
||||||
async def load_copilots(root):
|
async def load_copilots(root):
|
||||||
return await flight_copilots_dataloader.load(root.id)
|
return await flight_copilots_dataloader.load(root.id)
|
||||||
@@ -172,7 +160,7 @@ class Flight:
|
|||||||
resolver=lambda root: airport_dataloader.load(root.landing_airport_id)
|
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))
|
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(
|
takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(
|
||||||
resolver=lambda root: airport_weather_info_loader.load(root.takeoff_weather_info_id)
|
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)
|
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))
|
photos: List[Photo] = strawberry.field(resolver=lambda root: photos_dataloader.load(root.id))
|
||||||
gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track)
|
track: Optional[Track] = strawberry.field(resolver=lambda root: track_dataloader.load(root.track_id))
|
||||||
duration_min_calculated: int = strawberry.field(
|
duration_min_calculated: int = strawberry.field(resolver=lambda root: flight_duration_dataloader.load(root.id))
|
||||||
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(
|
debriefing_notes: Optional[str] = strawberry.field(
|
||||||
permission_classes=[IsAuthenticated],
|
permission_classes=[IsAuthenticated],
|
||||||
resolver=lambda root: root.debriefing_notes
|
resolver=lambda root: root.debriefing_notes
|
||||||
@@ -197,7 +185,7 @@ class FlightPlanMarker:
|
|||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
class FlightPlanTrack:
|
class FlightPlanNavlog:
|
||||||
bearing: Optional[int]
|
bearing: Optional[int]
|
||||||
distance: Optional[float]
|
distance: Optional[float]
|
||||||
from_: FlightPlanMarker = strawberry.field(name="from")
|
from_: FlightPlanMarker = strawberry.field(name="from")
|
||||||
@@ -206,11 +194,11 @@ class FlightPlanTrack:
|
|||||||
|
|
||||||
@strawberry_sqlalchemy_type(model=models.FlightPlan)
|
@strawberry_sqlalchemy_type(model=models.FlightPlan)
|
||||||
class FlightPlan:
|
class FlightPlan:
|
||||||
async def resolve_track(self):
|
async def resolve_navlog(self):
|
||||||
markers = await self.markers()
|
markers = await self.markers()
|
||||||
|
|
||||||
if len(markers) == 1:
|
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 = []
|
navigation = []
|
||||||
for i in range(len(markers) - 1):
|
for i in range(len(markers) - 1):
|
||||||
@@ -220,7 +208,7 @@ class FlightPlan:
|
|||||||
point_from = (from_.gps_latitude, from_.gps_longitude)
|
point_from = (from_.gps_latitude, from_.gps_longitude)
|
||||||
point_to = (to.gps_latitude, to.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),
|
bearing=round(math.degrees(get_bearing(point_from, point_to)) % 360),
|
||||||
distance=round(get_distance(point_from, point_to), 1),
|
distance=round(get_distance(point_from, point_to), 1),
|
||||||
from_=from_,
|
from_=from_,
|
||||||
@@ -235,9 +223,8 @@ class FlightPlan:
|
|||||||
markers: list[FlightPlanMarker] = strawberry.field(
|
markers: list[FlightPlanMarker] = strawberry.field(
|
||||||
resolver=lambda root: flight_plan_markers_dataloader.load(root.id)
|
resolver=lambda root: flight_plan_markers_dataloader.load(root.id)
|
||||||
)
|
)
|
||||||
track: Optional[list[FlightPlanTrack]] = strawberry.field(
|
navlog: Optional[list[FlightPlanNavlog]] = strawberry.field(resolver=resolve_navlog)
|
||||||
resolver=resolve_track
|
track: Optional[Track] = strawberry.field(resolver=lambda root: track_dataloader.load(root.track_id))
|
||||||
)
|
|
||||||
copilots: List[Copilot] = strawberry.field(resolver=load_copilots)
|
copilots: List[Copilot] = strawberry.field(resolver=load_copilots)
|
||||||
aircraft: Optional[Aircraft] = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id))
|
aircraft: Optional[Aircraft] = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id))
|
||||||
|
|
||||||
@@ -285,6 +272,9 @@ class Organization:
|
|||||||
class User:
|
class User:
|
||||||
avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root))
|
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))
|
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(
|
organizations: List[Organization] = strawberry.field(
|
||||||
resolver=lambda root: user_organizations_dataloader.load(root.id)
|
resolver=lambda root: user_organizations_dataloader.load(root.id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from decorators.error_logging import error_logging
|
|||||||
from database.transaction import get_session
|
from database.transaction import get_session
|
||||||
from graphql_schema.entities.types.types import User
|
from graphql_schema.entities.types.types import User
|
||||||
from utils.file import delete_file
|
from utils.file import delete_file
|
||||||
from utils.upload import handle_file_upload
|
from utils.file import handle_file_upload
|
||||||
|
|
||||||
|
|
||||||
@strawberry.type
|
@strawberry.type
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@ from starlette.background import BackgroundTasks
|
|||||||
from starlette.middleware.cors import CORSMiddleware
|
from starlette.middleware.cors import CORSMiddleware
|
||||||
from starlette.responses import Response, JSONResponse
|
from starlette.responses import Response, JSONResponse
|
||||||
from starlette.staticfiles import StaticFiles
|
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.contact import ContactEndpoint, ContactInput
|
||||||
from endpoints.forgotten_password import ForgottenPasswordRequest, ForgottenPasswordEndpoint, ChangeForgottenPassword
|
from endpoints.forgotten_password import ForgottenPasswordRequest, ForgottenPasswordEndpoint, ChangeForgottenPassword
|
||||||
from endpoints.graphql import setup_graphql_endpoint
|
from endpoints.graphql import setup_graphql_endpoint
|
||||||
@@ -35,6 +35,7 @@ class App:
|
|||||||
access_security = JwtAccessBearerCookie(
|
access_security = JwtAccessBearerCookie(
|
||||||
secret_key=APP_SECRET_KEY,
|
secret_key=APP_SECRET_KEY,
|
||||||
auto_error=False,
|
auto_error=False,
|
||||||
|
access_expires_delta=timedelta(days=1) if APP_DEBUG else timedelta(minutes=20)
|
||||||
)
|
)
|
||||||
refresh_security = JwtRefreshBearerCookie(
|
refresh_security = JwtRefreshBearerCookie(
|
||||||
secret_key=APP_SECRET_KEY,
|
secret_key=APP_SECRET_KEY,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ def get_public_url(filename: Optional[str]) -> str:
|
|||||||
return f"{API_URL}/uploads/{filename}" if filename else None
|
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:
|
def get_photo_url(root) -> str:
|
||||||
filename = root.filename if not root.filename_extension else f"{root.filename}.{root.filename_extension}"
|
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}")
|
return get_public_url(f"photos/{root.flight_id}/{filename}?cache={root.cache_key}")
|
||||||
|
|||||||
@@ -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
@@ -1,75 +1,52 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
sys.path.insert(0, "/app/src")
|
sys.path.insert(0, "/app/src")
|
||||||
from paths import FLIGHT_GPX_TRACK_PATH # noqa
|
from paths import FLIGHT_GPX_TRACK_PATH # noqa
|
||||||
from database import async_session, models # 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 external.gpx_parser import GPXParser # noqa
|
||||||
|
from database.transaction import get_session
|
||||||
|
|
||||||
|
|
||||||
async def add_elevation_to_photos():
|
async def add_elevation_to_photos():
|
||||||
async with async_session() as session:
|
async with get_session() as db:
|
||||||
photos = (await session.scalars(
|
photos = (await db.scalars(
|
||||||
select(models.Photo)
|
select(models.Photo)
|
||||||
.filter(models.Photo.terrain_elevation.is_(None))
|
.filter(models.Photo.terrain_elevation.is_(None))
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
coordinates = [
|
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:
|
if not coordinates:
|
||||||
print("all done")
|
print("all done")
|
||||||
return
|
return
|
||||||
|
|
||||||
points = await elevation_api.get_elevation_for_points(coordinates)
|
points = await elevation_api.get_elevation_for_points(coordinates)
|
||||||
for point in points:
|
for point in points:
|
||||||
photo = photos_by_corrdinates[point['lat'], point['lng']]
|
await models.Photo.update(db_session=db, id=point.id, data={"terrain_elevation": point.elevation})
|
||||||
await models.Photo.update(db_session=session, obj=photo, data={"terrain_elevation": point['elevation']})
|
|
||||||
await session.flush()
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def add_elevation_to_tracks():
|
async def add_elevation_to_tracks():
|
||||||
async with async_session() as session:
|
async with get_session() as db:
|
||||||
flights = (await session.scalars(
|
track_points = (await db.scalars(
|
||||||
select(models.Flight)
|
select(models.TrackPoint)
|
||||||
.filter(models.Flight.has_terrain_elevation.is_(False))
|
.filter(models.TrackPoint.terrain_elevation.is_(None))
|
||||||
.filter(models.Flight.gpx_track_filename.isnot(None))
|
|
||||||
)).all()
|
)).all()
|
||||||
|
|
||||||
if not flights:
|
if not track_points:
|
||||||
print("all done")
|
print("all done")
|
||||||
return
|
return
|
||||||
|
await update_track_points_elevation(db, track_points)
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
async def run_all():
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from strawberry.file_uploads import Upload
|
||||||
|
|
||||||
|
|
||||||
def delete_file(path: str, silent: bool = False):
|
def delete_file(path: str, silent: bool = False):
|
||||||
@@ -14,3 +17,31 @@ def check_directories(path: str):
|
|||||||
os.makedirs(path, exist_ok=True)
|
os.makedirs(path, exist_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
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
|
||||||
|
|||||||
@@ -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]
|
||||||
|
})
|
||||||
@@ -1,6 +1,63 @@
|
|||||||
from typing import Tuple
|
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:
|
def gps_to_decimal(input: Tuple[float, float, float]) -> float:
|
||||||
d, m, s = input
|
d, m, s = input
|
||||||
return d + (m / 60.0) + (s / 3600.0)
|
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
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -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
Reference in New Issue
Block a user