Author SHA1 Message Date
Michal Kváček 25b0beb958 Vertikalni profil trasy, generovani bodu v planu 2024-08-22 16:22:20 +02:00
Michal Kváček 2e70f7bc2b Merge branch 'master' of kvacek.cz:poletani.cz/api into flight_plan_track 2024-08-12 06:27:47 +02:00
Michal Kváček 94639da820 Sada bugfixu 2024-08-12 06:27:28 +02:00
Michal Kváček 90eec72d0e WIP: generovani trasy planu 2024-08-07 06:15:37 +02:00
Michal Kváček 9e6b0b574c Stahovani elevace 2024-07-29 06:23:06 +02:00
Michal Kváček 10bc0847b6 Trasu z GPX nahravat do DB 2024-07-25 19:50:09 +02:00
Michal Kváček e541508f75 API pro podporu vytvareni letu z planu 2024-06-30 02:31:37 +02:00
Michal Kváček ac3b496459 Priprava modelu na GPX v DB 2024-06-24 16:22:50 +02:00
Michal Kváček 2bf737b9d6 Uprava stahovani elevace, slouceni gps utils do jednoho filu, retryable, pridani social image k pilotovi a letu 2024-06-03 23:07:23 +02:00
Michal Kváček ba5e73108f Stahovani pocasi, drobne upravy 2024-05-27 22:30:44 +02:00
Michal Kváček 94613a5b1b ZoNacitani frekvenci, RWY 2024-05-15 09:33:41 +02:00
Michal Kváček 62e46f37ce Bugfixing 2024-05-07 08:24:29 +02:00
Michal Kváček a706303155 Ukladani metadat k planum 2024-04-30 22:46:40 +02:00
Michal Kváček c8138860ca Nova struktura DB pro data z OpenAIP API 2024-04-29 06:44:33 +02:00
Michal Kváček c2b066d371 Fix nacitani trasy pro plan jen s jednim bodem 2024-04-17 13:17:59 +02:00
Michal Kváček cee5de003f Zakladni verze planovace 2024-04-15 18:13:37 +02:00
Michal Kváček 5f0a1da052 Merge branch 'master' of kvacek.cz:poletani.cz/api into planovac 2024-04-02 06:14:03 +02:00
Michal Kváček 63e2eed8c2 draft: crud pro planovac 2024-03-25 19:04:24 +01:00
Michal Kváček 01f8a616bf Merge branch 'master' of kvacek.cz:poletani.cz/api into planovac 2024-03-23 20:28:21 +01:00
Michal Kváček 24825108a7 Zaklad pro planovac letu 2024-03-23 20:15:47 +01:00
73 changed files with 2172 additions and 7464 deletions
+2
View File
@@ -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
View File
@@ -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/
@@ -0,0 +1,38 @@
"""add flight_plan table
Revision ID: 7a3738a805c5
Revises: aba4c3876b28
Create Date: 2024-03-21 16:42:08.428875
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '7a3738a805c5'
down_revision = 'aba4c3876b28'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('flight_plan',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('markers', mysql.JSON(), nullable=False),
sa.Column('is_public', sa.Boolean(), server_default='0', 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.Column('deleted', sa.Boolean(), server_default='0', nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('flight_plan')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""make markers in flight planner separate table
Revision ID: de70fc471699
Revises: 7a3738a805c5
Create Date: 2024-03-24 21:55:12.307588
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'de70fc471699'
down_revision = '7a3738a805c5'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('flight_plan_marker',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('flight_plan_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('gps_latitude', sa.Float(), nullable=True),
sa.Column('gps_longitude', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['flight_plan_id'], ['flight_plan.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_column('flight_plan', 'markers')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan', sa.Column('markers', mysql.LONGTEXT(charset='utf8mb4', collation='utf8mb4_bin'), nullable=False))
op.drop_table('flight_plan_marker')
# ### end Alembic commands ###
@@ -0,0 +1,42 @@
"""add type and position to flight_plan_marker
Revision ID: 70152c47cb49
Revises: de70fc471699
Create Date: 2024-03-24 22:00:35.234920
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '70152c47cb49'
down_revision = 'de70fc471699'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan_marker', sa.Column('type', sa.String(length=64), nullable=False))
op.add_column('flight_plan_marker', sa.Column('position', sa.Integer(), nullable=False))
op.alter_column('flight_plan_marker', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=False)
op.alter_column('flight_plan_marker', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('flight_plan_marker', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('flight_plan_marker', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=True)
op.drop_column('flight_plan_marker', 'position')
op.drop_column('flight_plan_marker', 'type')
# ### end Alembic commands ###
@@ -0,0 +1,116 @@
"""reworked airspace and airport db structure
Revision ID: cbdb018c5490
Revises: 70152c47cb49
Create Date: 2024-04-21 22:32:50.058166
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'cbdb018c5490'
down_revision = '70152c47cb49'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('airspace',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('country', sa.String(length=8), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('type', sa.String(length=8), nullable=False),
sa.Column('lower_limit', sa.String(length=32), nullable=False),
sa.Column('upper_limit', sa.String(length=32), nullable=False),
sa.Column('center', mysql.JSON(), nullable=False),
sa.Column('radius', sa.Float(), nullable=True),
sa.Column('bounds', mysql.JSON(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_airspace_country'), 'airspace', ['country'], unique=False)
op.create_index(op.f('ix_airspace_type'), 'airspace', ['type'], unique=False)
op.create_table('frequency',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('airport_id', sa.Integer(), nullable=True),
sa.Column('airspace_id', sa.Integer(), nullable=True),
sa.Column('frequency', sa.String(length=7), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.ForeignKeyConstraint(['airport_id'], ['airport.id'], ),
sa.ForeignKeyConstraint(['airspace_id'], ['airspace.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('runway',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('airport_id', sa.Integer(), nullable=False),
sa.Column('heading', sa.Integer(), nullable=False),
sa.Column('designator', sa.String(length=4), nullable=False),
sa.Column('surface', sa.Enum('asphalt', 'concrete', 'grass', 'other'), server_default='other', nullable=False),
sa.Column('length', sa.Integer(), nullable=True),
sa.Column('width', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['airport_id'], ['airport.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.drop_table('metar')
op.drop_table('license')
op.drop_index('ix_airspace_item_country', table_name='airspace_item')
op.drop_index('ix_airspace_item_type', table_name='airspace_item')
op.drop_index('name', table_name='airspace_item')
op.drop_table('airspace_item')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('airspace_item',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('country', mysql.VARCHAR(length=8), nullable=False),
sa.Column('name', mysql.VARCHAR(length=128), nullable=False),
sa.Column('type', mysql.VARCHAR(length=8), nullable=False),
sa.Column('lower_limit', mysql.VARCHAR(length=32), nullable=False),
sa.Column('upper_limit', mysql.VARCHAR(length=32), nullable=False),
sa.Column('center', mysql.LONGTEXT(charset='utf8mb4', collation='utf8mb4_bin'), nullable=False),
sa.Column('radius', mysql.FLOAT(), nullable=True),
sa.Column('bounds', mysql.LONGTEXT(charset='utf8mb4', collation='utf8mb4_bin'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_index('name', 'airspace_item', ['name'], unique=True)
op.create_index('ix_airspace_item_type', 'airspace_item', ['type'], unique=False)
op.create_index('ix_airspace_item_country', 'airspace_item', ['country'], unique=False)
op.create_table('license',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('name', mysql.VARCHAR(length=128), nullable=False),
sa.Column('number', mysql.VARCHAR(length=30), nullable=False),
sa.Column('valid_until', mysql.DATETIME(), nullable=False),
sa.Column('created_by_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('created_at', mysql.DATETIME(), server_default=sa.text('current_timestamp()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], name='license_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.create_table('metar',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('airport_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('metar', mysql.TEXT(), nullable=False),
sa.Column('issued_at', mysql.DATETIME(), nullable=False),
sa.Column('deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False),
sa.ForeignKeyConstraint(['airport_id'], ['airport.id'], name='metar_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
op.drop_table('runway')
op.drop_table('frequency')
op.drop_index(op.f('ix_airspace_type'), table_name='airspace')
op.drop_index(op.f('ix_airspace_country'), table_name='airspace')
op.drop_table('airspace')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""remove old airspace columns, add altitude unit
Revision ID: e07a5c0f1dcc
Revises: cbdb018c5490
Create Date: 2024-04-22 23:42:52.140389
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'e07a5c0f1dcc'
down_revision = 'cbdb018c5490'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airspace', sa.Column('altitude_unit', sa.String(length=8), nullable=False))
op.drop_column('airspace', 'center')
op.drop_column('airspace', 'radius')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airspace', sa.Column('radius', mysql.FLOAT(), nullable=True))
op.add_column('airspace', sa.Column('center', mysql.LONGTEXT(charset='utf8mb4', collation='utf8mb4_bin'), nullable=False))
op.drop_column('airspace', 'altitude_unit')
# ### end Alembic commands ###
@@ -0,0 +1,32 @@
"""add alt unit for lower/upper limits in airspaces
Revision ID: 2012eca075c7
Revises: e07a5c0f1dcc
Create Date: 2024-04-22 23:49:17.725111
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '2012eca075c7'
down_revision = 'e07a5c0f1dcc'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airspace', sa.Column('lower_alt_unit', sa.String(length=8), nullable=False))
op.add_column('airspace', sa.Column('upper_alt_unit', sa.String(length=8), nullable=False))
op.drop_column('airspace', 'altitude_unit')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airspace', sa.Column('altitude_unit', mysql.VARCHAR(length=8), nullable=False))
op.drop_column('airspace', 'upper_alt_unit')
op.drop_column('airspace', 'lower_alt_unit')
# ### end Alembic commands ###
@@ -0,0 +1,45 @@
"""add table for reporting points
Revision ID: 03ba4dfea911
Revises: 2012eca075c7
Create Date: 2024-04-23 21:39:18.254033
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03ba4dfea911'
down_revision = '2012eca075c7'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('reporting_point',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('openaip_id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=64), nullable=False),
sa.Column('gps_latitude', sa.Float(), nullable=True),
sa.Column('gps_longitude', sa.Float(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('airport_has_reporting_point',
sa.Column('airport_id', sa.Integer(), nullable=False),
sa.Column('reporting_point_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['airport_id'], ['airport.id'], ),
sa.ForeignKeyConstraint(['reporting_point_id'], ['reporting_point.id'], ),
sa.PrimaryKeyConstraint('airport_id', 'reporting_point_id')
)
op.add_column('airport', sa.Column('openaip_id', sa.String(length=32), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('airport', 'openaip_id')
op.drop_table('airport_has_reporting_point')
op.drop_table('reporting_point')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""remove obsolete airport columns
Revision ID: f7ab4bdac987
Revises: 03ba4dfea911
Create Date: 2024-04-24 06:24:48.219726
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f7ab4bdac987'
down_revision = '03ba4dfea911'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('airport', 'deleted')
op.drop_column('airport', 'is_public')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('airport', sa.Column('is_public', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.add_column('airport', sa.Column('deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
# ### end Alembic commands ###
@@ -0,0 +1,56 @@
"""add flight plan meta information
Revision ID: 790ba00fc021
Revises: f7ab4bdac987
Create Date: 2024-04-29 16:40:09.829317
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '790ba00fc021'
down_revision = 'f7ab4bdac987'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('flight_plan_has_copilot',
sa.Column('flight_plan_id', sa.Integer(), nullable=False),
sa.Column('copilot_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=64), nullable=True),
sa.ForeignKeyConstraint(['copilot_id'], ['copilot.id'], ),
sa.ForeignKeyConstraint(['flight_plan_id'], ['flight_plan.id'], ),
sa.PrimaryKeyConstraint('flight_plan_id', 'copilot_id'),
sa.UniqueConstraint('token')
)
op.add_column('flight_plan', sa.Column('is_default_name', sa.Boolean(), server_default='0', nullable=False))
op.add_column('flight_plan', sa.Column('is_editable_by_copilot', sa.Boolean(), server_default='0', nullable=False))
op.add_column('flight_plan', sa.Column('planned_speed', sa.Integer(), nullable=False))
op.add_column('flight_plan', sa.Column('planned_takeoff_datetime', sa.DateTime(), nullable=True))
op.add_column('flight_plan', sa.Column('aircraft_id', sa.Integer(), nullable=True))
op.alter_column('flight_plan', 'name',
existing_type=mysql.VARCHAR(length=128),
type_=sa.String(length=256),
existing_nullable=False)
op.create_foreign_key(None, 'flight_plan', 'aircraft', ['aircraft_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan', type_='foreignkey')
op.alter_column('flight_plan', 'name',
existing_type=sa.String(length=256),
type_=mysql.VARCHAR(length=128),
existing_nullable=False)
op.drop_column('flight_plan', 'aircraft_id')
op.drop_column('flight_plan', 'planned_takeoff_datetime')
op.drop_column('flight_plan', 'planned_speed')
op.drop_column('flight_plan', 'is_editable_by_copilot')
op.drop_column('flight_plan', 'is_default_name')
op.drop_table('flight_plan_has_copilot')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""add weather info to flight plan markers
Revision ID: 8a67af3909aa
Revises: 790ba00fc021
Create Date: 2024-05-16 06:19:13.112911
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '8a67af3909aa'
down_revision = '790ba00fc021'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan_marker', sa.Column('weather_info_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan_marker', 'weather_info', ['weather_info_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_column('flight_plan_marker', 'weather_info_id')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add flight_plan.description
Revision ID: a3e1f1a37899
Revises: 8a67af3909aa
Create Date: 2024-06-11 16:46:29.786830
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a3e1f1a37899'
down_revision = '8a67af3909aa'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_plan', sa.Column('description', sa.Text(), server_default='', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('flight_plan', 'description')
# ### end Alembic commands ###
@@ -0,0 +1,54 @@
"""add track to flight
Revision ID: f9e1e8a01683
Revises: a3e1f1a37899
Create Date: 2024-06-24 16:08:01.084570
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'f9e1e8a01683'
down_revision = 'a3e1f1a37899'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('track',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('bounds', mysql.JSON(), nullable=False),
sa.Column('created_by_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('track_point',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('track_id', sa.Integer(), nullable=False),
sa.Column('gps_latitude', sa.Float(), nullable=False),
sa.Column('gps_longitude', sa.Float(), nullable=False),
sa.Column('elevation', sa.Float(), nullable=True),
sa.ForeignKeyConstraint(['track_id'], ['track.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.add_column('flight', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'track', ['track_id'], ['id'])
op.add_column('flight_plan', sa.Column('track_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan', 'track', ['track_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan', type_='foreignkey')
op.drop_column('flight_plan', 'track_id')
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'track_id')
op.drop_table('track_point')
op.drop_table('track')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""add flight.flight_plan_id fk
Revision ID: 989152b7184a
Revises: f9e1e8a01683
Create Date: 2024-06-27 17:09:19.933094
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '989152b7184a'
down_revision = 'f9e1e8a01683'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight', sa.Column('flight_plan_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'flight_plan', ['flight_plan_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'flight_plan_id')
# ### end Alembic commands ###
@@ -0,0 +1,40 @@
"""Add poi and airport id to flight plan track marker
Revision ID: 8cc01c03e980
Revises: 989152b7184a
Create Date: 2024-06-30 00:27:08.976891
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '8cc01c03e980'
down_revision = '989152b7184a'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('airport', 'openaip_id',
existing_type=mysql.VARCHAR(length=32),
nullable=True)
op.add_column('flight_plan_marker', sa.Column('airport_id', sa.Integer(), nullable=True))
op.add_column('flight_plan_marker', sa.Column('point_of_interest_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight_plan_marker', 'point_of_interest', ['point_of_interest_id'], ['id'])
op.create_foreign_key(None, 'flight_plan_marker', 'airport', ['airport_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_constraint(None, 'flight_plan_marker', type_='foreignkey')
op.drop_column('flight_plan_marker', 'point_of_interest_id')
op.drop_column('flight_plan_marker', 'airport_id')
op.alter_column('airport', 'openaip_id',
existing_type=mysql.VARCHAR(length=32),
nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,46 @@
"""add speed, elevation, altitude to track
Revision ID: 03de3f7f8fdd
Revises: 8cc01c03e980
Create Date: 2024-07-11 07:45:59.376941
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '03de3f7f8fdd'
down_revision = '8cc01c03e980'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('track', sa.Column('min_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('max_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('avg_speed', sa.Float(), nullable=True))
op.add_column('track', sa.Column('max_altitude', sa.Float(), nullable=True))
op.add_column('track', sa.Column('avg_altitude', sa.Float(), nullable=True))
op.add_column('track', sa.Column('total_duration', sa.Integer(), nullable=True, comment='Total duration in seconds'))
op.add_column('track_point', sa.Column('terrain_elevation', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('speed', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('altitude', sa.Float(), nullable=True))
op.add_column('track_point', sa.Column('magnetic_variation', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('track_point', 'magnetic_variation')
op.drop_column('track_point', 'altitude')
op.drop_column('track_point', 'speed')
op.drop_column('track_point', 'terrain_elevation')
op.drop_column('track', 'total_duration')
op.drop_column('track', 'avg_altitude')
op.drop_column('track', 'max_altitude')
op.drop_column('track', 'avg_speed')
op.drop_column('track', 'max_speed')
op.drop_column('track', 'min_speed')
# ### end Alembic commands ###
@@ -0,0 +1,33 @@
"""delete duplicated column with altitude
Revision ID: e3620deb41b6
Revises: 03de3f7f8fdd
Create Date: 2024-07-15 06:35:13.536320
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'e3620deb41b6'
down_revision = '03de3f7f8fdd'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.rename_table("flight_track", "flight_turn_point")
op.create_index(op.f('ix_track_point_timestamp'), 'track_point', ['timestamp'], unique=False)
op.drop_column('track_point', 'elevation')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('track_point', sa.Column('elevation', mysql.FLOAT(), nullable=True))
op.drop_index(op.f('ix_track_point_timestamp'), table_name='track_point')
op.rename_table("flight_turn_point", "flight_track")
# ### end Alembic commands ###
-4
View File
@@ -1,4 +0,0 @@
pytest
mypy
flake8
pytest-asyncio
+2 -1
View File
@@ -7,8 +7,9 @@ services:
network_mode: "host" network_mode: "host"
restart: "always" restart: "always"
env_file: env_file:
- .env - ../.env
environment: environment:
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
+16 -21
View File
@@ -3,10 +3,8 @@ services:
image: mariadb:10.11 image: mariadb:10.11
ports: ports:
- 3306:3306 - 3306:3306
environment: env_file:
- MYSQL_USER=root - .env
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=ull_tracker
volumes: volumes:
# - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- db-data:/var/lib/mysql:rw - db-data:/var/lib/mysql:rw
@@ -28,23 +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
environment: # env_file:
- MYSQL_USER=root # - .env
- MYSQL_PASSWORD=root # volumes:
- MYSQL_DATABASE=ull_tracker # - db-data:/var/lib/mysql:rw
- MYSQL_HOST=db # - ../crawlers:/app
volumes:
- db-data:/var/lib/mysql:rw
- ../crawlers:/app
volumes: volumes:
db-data: { } db-data: { }
-2
View File
@@ -1,2 +0,0 @@
[pytest]
asyncio_mode = auto
+1
View File
@@ -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
+19 -25
View File
@@ -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() 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:
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime, timedelta
from sqlalchemy import select, delete
from database import models
from database.transaction import get_session
from utils.gps import generate_points
async def generate_track_for_flight_plan(flight_plan_id: int):
async with (get_session() as db):
flight_plan = await models.FlightPlan.get_one(db, id=flight_plan_id)
turn_points = (
await db.scalars(
select(models.FlightPlanMarker)
.filter(models.FlightPlanMarker.flight_plan_id == flight_plan_id)
)).all()
if len(turn_points) == 1:
return
if flight_plan.track_id:
track = await models.Track.get_one(db, id=flight_plan.track_id)
await db.execute(
delete(models.TrackPoint)
.filter(models.TrackPoint.track_id == track.id)
)
else:
track = await models.Track.create(db, {"bounds": [], "created_by_id": flight_plan.created_by_id})
flight_plan.track_id = track.id
point_ts = flight_plan.planned_takeoff_datetime or datetime.now()
track_points_resolution_meters = 500
speed_m_s = flight_plan.planned_speed / 3.6
delta_t_sec = track_points_resolution_meters / speed_m_s
for i in range(len(turn_points) - 1):
from_ = turn_points[i]
to = turn_points[i + 1]
point_from = (from_.gps_latitude, from_.gps_longitude)
point_to = (to.gps_latitude, to.gps_longitude)
points_between = generate_points(point_from, point_to)
for point in points_between:
await models.TrackPoint.create(db, {
"track_id": track.id,
"timestamp": point_ts,
"gps_latitude": point.latitude,
"gps_longitude": point.longitude,
})
point_ts += timedelta(seconds=delta_t_sec)
+58
View File
@@ -0,0 +1,58 @@
from PIL import Image, ImageDraw, ImageOps
async def add_circular_avatar(base_image_path, avatar_image_path, output_image_path):
# Load the base image
base_image = Image.open(base_image_path)
base_width, base_height = base_image.size
# Calculate the new dimensions for cropping to a 1.9:1 aspect ratio
new_width = base_width
new_height = int(new_width / 1.9)
if new_height > base_height:
new_height = base_height
new_width = int(new_height * 1.9)
# Center crop the base image to the new dimensions
left = (base_width - new_width) // 2
top = (base_height - new_height) // 2
right = (base_width + new_width) // 2
bottom = (base_height + new_height) // 2
base_image = base_image.crop((left, top, right, bottom))
base_width, base_height = base_image.size
# Load the avatar image
avatar_image = Image.open(avatar_image_path)
avatar_size = int(base_height * 0.3) # Set the size of the avatar
avatar_image = avatar_image.resize((avatar_size, avatar_size), Image.LANCZOS)
# Create a mask to make the avatar circular
mask = Image.new('L', (avatar_size, avatar_size), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse((0, 0, avatar_size, avatar_size), fill=255)
# Apply the mask to the avatar image
avatar_image = ImageOps.fit(avatar_image, mask.size, centering=(0.5, 0.5))
avatar_image.putalpha(mask)
# Create a border around the avatar
border_size = int(avatar_size * 0.03)
border_image = Image.new('RGBA', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), (255, 255, 255, 0))
border_mask = Image.new('L', (avatar_size + 2 * border_size, avatar_size + 2 * border_size), 0)
border_draw = ImageDraw.Draw(border_mask)
border_draw.ellipse((0, 0, avatar_size + 2 * border_size, avatar_size + 2 * border_size), fill=255)
border_draw.ellipse((border_size, border_size, avatar_size + border_size, avatar_size + border_size), fill=0)
avatar_margin = int(avatar_size * 0.1)
border_image.putalpha(border_mask)
border_image.paste(avatar_image, (border_size, border_size), avatar_image)
# Calculate the position to paste the avatar (bottom right corner)
position = (base_width - avatar_size - 2 * border_size - avatar_margin, base_height - avatar_size - 2 * border_size - avatar_margin)
# Paste the avatar with border onto the base image
base_image.paste(border_image, position, border_image)
# Save the result
base_image.save(output_image_path)
+27 -7
View File
@@ -6,19 +6,14 @@ from external.weather import weather_api
from logger import log 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
View File
@@ -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
+137 -57
View File
@@ -59,6 +59,14 @@ flight_has_copilot = Table(
Column("token", String(64), unique=True) Column("token", String(64), unique=True)
) )
flight_plan_has_copilot = Table(
"flight_plan_has_copilot",
BaseModel.metadata,
Column("flight_plan_id", ForeignKey("flight_plan.id"), primary_key=True),
Column("copilot_id", ForeignKey("copilot.id"), primary_key=True),
Column("token", String(64), unique=True)
)
copilot_has_photo = Table( copilot_has_photo = Table(
"copilot_has_photo", "copilot_has_photo",
BaseModel.metadata, BaseModel.metadata,
@@ -66,41 +74,153 @@ 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",
BaseModel.metadata,
Column("airport_id", ForeignKey("airport.id"), primary_key=True),
Column("reporting_point_id", ForeignKey("reporting_point.id"), primary_key=True),
)
class AirspaceItem(BaseModel):
__tablename__ = "airspace_item" class Airspace(BaseModel):
__tablename__ = "airspace"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
country: Mapped[str] = mapped_column(String(8), nullable=False, index=True) country: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
type: Mapped[str] = mapped_column(String(8), nullable=False, index=True) type: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
lower_limit: Mapped[str] = mapped_column(String(32), nullable=False) lower_limit: Mapped[str] = mapped_column(String(32), nullable=False)
lower_alt_unit: Mapped[str] = mapped_column(String(8), nullable=False)
upper_limit: Mapped[str] = mapped_column(String(32), nullable=False) upper_limit: Mapped[str] = mapped_column(String(32), nullable=False)
center: Mapped[List[dict]] = mapped_column(JSON()) upper_alt_unit: Mapped[str] = mapped_column(String(8), nullable=False)
radius: Mapped[float] = mapped_column(Float, nullable=True)
bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON()) bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON())
class FlightPlan(BaseModel):
__tablename__ = "flight_plan"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False)
is_default_name: Mapped[bool] = mapped_column(Boolean, server_default='0')
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
is_editable_by_copilot: Mapped[bool] = mapped_column(Boolean, server_default='0')
track_id: Mapped[int] = mapped_column(Integer, ForeignKey('track.id'), nullable=True)
description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
planned_speed: Mapped[int] = mapped_column(Integer, nullable=False, default=150)
planned_takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=True)
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=True)
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
copilots: Mapped[List['Copilot']] = relationship(secondary=flight_plan_has_copilot)
aircraft: Mapped['Aircraft'] = relationship()
markers: Mapped[List['FlightPlanMarker']] = relationship()
created_by: Mapped['User'] = relationship()
class Track(BaseModel):
__tablename__ = "track"
id: Mapped[int] = mapped_column(primary_key=True)
bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON())
min_speed: Mapped[float] = mapped_column(Float, nullable=True)
max_speed: Mapped[float] = mapped_column(Float, nullable=True)
avg_speed: Mapped[float] = mapped_column(Float, nullable=True)
max_altitude: Mapped[float] = mapped_column(Float, nullable=True)
avg_altitude: Mapped[float] = mapped_column(Float, nullable=True)
total_duration: Mapped[int] = mapped_column(Integer, nullable=True, comment="Total duration in seconds")
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
flight: Mapped['Flight'] = relationship()
flight_plan: Mapped['FlightPlan'] = relationship()
# created_by: Mapped['User'] = relationship()
class TrackPoint(BaseModel):
__tablename__ = "track_point"
id: Mapped[int] = mapped_column(primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
track_id: Mapped[id] = mapped_column(Integer, ForeignKey('track.id'))
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
terrain_elevation: Mapped[float] = mapped_column(Float, nullable=True)
speed: Mapped[float] = mapped_column(Float, nullable=True)
altitude: Mapped[float] = mapped_column(Float, nullable=True)
magnetic_variation: Mapped[float] = mapped_column(Float, nullable=True)
track: Mapped['Track'] = relationship()
class FlightPlanMarker(BaseModel):
__tablename__ = "flight_plan_marker"
id: Mapped[int] = mapped_column(primary_key=True)
flight_plan_id: Mapped[int] = mapped_column(Integer, ForeignKey('flight_plan.id'))
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True)
weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
type: Mapped[str] = mapped_column(String(64), nullable=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
class Airport(BaseModel): class Airport(BaseModel):
__tablename__ = "airport" __tablename__ = "airport"
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=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)
elevation: Mapped[int] = mapped_column(Integer, nullable=True) elevation: Mapped[int] = mapped_column(Integer, nullable=True)
airport_type: Mapped[str] = mapped_column(Enum("airport", "ull", "heliport"), nullable=False, server_default='airport') # noqa airport_type: Mapped[str] = mapped_column(Enum("airport", "ull", "heliport"), nullable=False, server_default='airport') # noqa
use_in_gpx_guess: Mapped[bool] = mapped_column(Boolean, server_default='1') use_in_gpx_guess: Mapped[bool] = mapped_column(Boolean, server_default='1')
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
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
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
metars: Mapped['Metar'] = relationship() runways: Mapped[list['Runway']] = relationship('Runway')
frequencies: Mapped[list['Frequency']] = relationship()
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
class Runway(BaseModel):
__tablename__ = "runway"
id: Mapped[int] = mapped_column(primary_key=True)
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey('airport.id'))
heading: Mapped[int] = mapped_column(Integer, nullable=False)
designator: Mapped[str] = mapped_column(String(4), nullable=False)
surface: Mapped[str] = mapped_column(Enum("asphalt", "concrete", "grass", "other"), nullable=False, server_default="other") # noqa
length: Mapped[int] = mapped_column(Integer, nullable=True)
width: Mapped[int] = mapped_column(Integer, nullable=True)
class Frequency(BaseModel):
__tablename__ = "frequency"
id: Mapped[int] = mapped_column(primary_key=True)
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey('airport.id'), nullable=True)
airspace_id: Mapped[int] = mapped_column(Integer, ForeignKey('airspace.id'), nullable=True)
frequency: Mapped[str] = mapped_column(String(7), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
class ReportingPoint(BaseModel):
__tablename__ = "reporting_point"
id: Mapped[int] = mapped_column(primary_key=True)
openaip_id: Mapped[str] = mapped_column(String(32), nullable=False)
name: Mapped[str] = mapped_column(String(64), nullable=False)
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=True)
class PointOfInterestType(BaseModel): class PointOfInterestType(BaseModel):
__tablename__ = "point_of_interest_type" __tablename__ = "point_of_interest_type"
@@ -207,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):
@@ -238,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()
@@ -303,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)
@@ -318,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"
@@ -340,37 +446,12 @@ class Copilot(BaseModel):
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
flights: Mapped[Set['Flight']] = relationship(secondary=flight_has_copilot) flights: Mapped[Set['Flight']] = relationship(secondary=flight_has_copilot)
flight_plans: Mapped[Set['FlightPlan']] = relationship(secondary=flight_plan_has_copilot)
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
photos: Mapped[List['Photo']] = relationship(secondary=copilot_has_photo) photos: Mapped[List['Photo']] = relationship(secondary=copilot_has_photo)
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id]) title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
class Metar(BaseModel):
__tablename__ = "metar"
id: Mapped[int] = mapped_column(primary_key=True)
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey('airport.id'))
metar: Mapped[str] = mapped_column(Text, nullable=False)
issued_at: Mapped[datetime] = mapped_column(DateTime)
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
airport: Mapped['Airport'] = relationship()
class License(BaseModel):
__tablename__ = "license"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
number: Mapped[str] = mapped_column(String(30), nullable=False)
valid_until: Mapped[datetime] = mapped_column(DateTime, nullable=False)
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped['User'] = relationship()
created_by: Mapped['User'] = relationship()
class User(BaseModel): class User(BaseModel):
__tablename__ = "user" __tablename__ = "user"
excluded_columns_in_dict = ('password_hashed',) excluded_columns_in_dict = ('password_hashed',)
@@ -385,7 +466,6 @@ class User(BaseModel):
password_hashed: Mapped[str] = mapped_column(String(60), nullable=False) password_hashed: Mapped[str] = mapped_column(String(60), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
licences: Mapped[Set['License']] = relationship()
flights: Mapped[Set['Flight']] = relationship() flights: Mapped[Set['Flight']] = relationship()
organizations: Mapped[Set['Organization']] = relationship(secondary=user_is_in_organization) organizations: Mapped[Set['Organization']] = relationship(secondary=user_is_in_organization)
+24
View File
@@ -0,0 +1,24 @@
import asyncio
def retryable(func):
max_retries = 5
async def func_wrapper(*args, **kwargs):
for i in range(1, max_retries):
try:
return await func(*args, **kwargs)
except Exception as ex:
if i > max_retries:
raise ex
sleep_time = i * 2
print(
"Error while getting the data from source:"
" decorator is going to sleep for %s seconds"
" and retry it again (%s/%s)" % (
sleep_time, i, max_retries
)
)
await asyncio.sleep(sleep_time)
return func_wrapper
+9 -10
View File
@@ -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
+2
View File
@@ -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,
+73 -13
View File
@@ -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()
+12 -39
View File
@@ -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,48 +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())
async def get_coordinates_all(self) -> List[Dict[str, float]]:
nodes = self.run_xpath("//gpx:trkpt") nodes = self.run_xpath("//gpx:trkpt")
return await self.average_coordinates( return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
[{"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:
@@ -117,33 +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)
extensions.append(etree.XML(f"<terrain_elevation>{elevation}</terrain_elevation>"))
return self.gpx
def write(self, tree: _ElementTree, output: str):
tree.write(output)
+19 -52
View File
@@ -8,12 +8,13 @@ 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 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
# http://www.winpilot.com/usersguide/userairspace.asp # http://www.winpilot.com/usersguide/userairspace.asp
class AirspaceItemType(enum.Enum): class AirspaceType(enum.Enum):
AIRSPACE_A = "A" AIRSPACE_A = "A"
AIRSPACE_B = "B" AIRSPACE_B = "B"
AIRSPACE_C = "C" AIRSPACE_C = "C"
@@ -46,8 +47,8 @@ class Coordinates:
@dataclasses.dataclass @dataclasses.dataclass
class AirspaceItem: class Airspace:
type: AirspaceItemType type: AirspaceType
info: str = "" info: str = ""
upper_limit: str = None upper_limit: str = None
lower_limit: str = None lower_limit: str = None
@@ -57,40 +58,6 @@ class AirspaceItem:
class ArcExpander: class ArcExpander:
R = 6378.1 # Radius of the Earth
@staticmethod
def get_bearing(center_point, end_point):
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
@classmethod
def get_distance(cls, a, b):
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 cls.R * c
@classmethod @classmethod
def get_arc_coordinates( def get_arc_coordinates(
cls, cls,
@@ -98,9 +65,9 @@ class ArcExpander:
origin_point: tuple[float, float], origin_point: tuple[float, float],
end_point: tuple[float, float] end_point: tuple[float, float]
) -> list[Coordinates]: ) -> list[Coordinates]:
initial_bearing = cls.get_bearing(center_point, origin_point) initial_bearing = get_bearing(center_point, origin_point)
target_bearing = cls.get_bearing(center_point, end_point) target_bearing = get_bearing(center_point, end_point)
distance = cls.get_distance(center_point, origin_point) distance = get_distance(center_point, origin_point)
print(f"DISTANCE {distance} meters, {center_point=}, {origin_point=}, {end_point=}") print(f"DISTANCE {distance} meters, {center_point=}, {origin_point=}, {end_point=}")
@@ -122,12 +89,12 @@ class ArcExpander:
for bearing in list_bearings: for bearing in list_bearings:
lat1, lon1 = math.radians(center_point[0]), math.radians(center_point[1]) lat1, lon1 = math.radians(center_point[0]), math.radians(center_point[1])
lat2 = math.asin( lat2 = math.asin(
math.sin(lat1) * math.cos(distance / cls.R) + math.cos(lat1) * math.sin(distance / cls.R) * math.cos(bearing) math.sin(lat1) * math.cos(distance / EARTH_RADIUS) + math.cos(lat1) * math.sin(distance / EARTH_RADIUS) * math.cos(bearing)
) )
lon2 = lon1 + math.atan2( lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(distance / cls.R) * math.cos(lat1), math.sin(bearing) * math.sin(distance / EARTH_RADIUS) * math.cos(lat1),
math.cos(distance / cls.R) - math.sin(lat1) * math.sin(lat2) math.cos(distance / EARTH_RADIUS) - math.sin(lat1) * math.sin(lat2)
) )
lat2, lon2 = math.degrees(lat2), math.degrees(lon2) lat2, lon2 = math.degrees(lat2), math.degrees(lon2)
@@ -140,7 +107,7 @@ class OpenAirParser:
def __init__(self, file: Path | str): def __init__(self, file: Path | str):
self.file = None self.file = None
self.context_type: Optional[AirspaceItemType] = None self.context_type: Optional[AirspaceType] = None
self.context = {} self.context = {}
self.file = file self.file = file
@@ -149,7 +116,7 @@ class OpenAirParser:
if not self.context_type: if not self.context_type:
return return
data = AirspaceItem(type=self.context_type, **self.context) data = Airspace(type=self.context_type, **self.context)
self.context_type = None self.context_type = None
self.context = {} self.context = {}
@@ -227,19 +194,19 @@ class OpenAirParser:
pass pass
case 'AC': case 'AC':
try: try:
self.context_type = AirspaceItemType(value) self.context_type = AirspaceType(value)
except ValueError as e: except ValueError as e:
print(f"Neznam {e}, ignoruji") print(f"Neznam {e}, ignoruji")
return return
case 'AN': case 'AN':
if self.context_type == AirspaceItemType.AIRSPACE_G: if self.context_type == AirspaceType.AIRSPACE_G:
match value[0:2]: match value[0:2]:
case 'PG': case 'PG':
self.context_type = AirspaceItemType.PARAGLIDING_ZONE self.context_type = AirspaceType.PARAGLIDING_ZONE
case 'LK': case 'LK':
self.context_type = AirspaceItemType.ATZ self.context_type = AirspaceType.ATZ
case _: case _:
self.context_type = AirspaceItemType.TRAGA self.context_type = AirspaceType.TRAGA
self.context['info'] = value self.context['info'] = value
case 'AL': case 'AL':
@@ -282,7 +249,7 @@ async def download_airspace(source, destination):
async def save_airspace(destination): async def save_airspace(destination):
parser = OpenAirParser(destination) parser = OpenAirParser(destination)
async with get_session() as db: async with get_session() as db:
await db.execute(delete(models.AirspaceItem)) await db.execute(delete(models.Airspace))
for item in parser.parse(): for item in parser.parse():
# if 'CTR KARLOVY VARY' not in item.info: # if 'CTR KARLOVY VARY' not in item.info:
@@ -294,7 +261,7 @@ async def save_airspace(destination):
# print("---" * 10) # print("---" * 10)
async with get_session() as db: async with get_session() as db:
await models.AirspaceItem.create(db, { await models.Airspace.create(db, {
"country": "cz", "country": "cz",
"name": item.info, "name": item.info,
"type": item.type.value, "type": item.type.value,
+2 -1
View File
@@ -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))
+66 -6
View File
@@ -20,9 +20,51 @@ flight_copilots_dataloader = DataLoader(
models.flight_has_copilot.c.flight_id == models.Flight.id, models.flight_has_copilot.c.flight_id == models.Flight.id,
models.flight_has_copilot.c.copilot_id == models.Copilot.id, models.flight_has_copilot.c.copilot_id == models.Copilot.id,
], ],
extra_join=[models.Copilot.flights]).load, extra_join=[models.Copilot.flights]
).load,
cache=False) cache=False)
flight_plan_copilots_dataloader = DataLoader(
load_fn=FlightCopilotDataloader(
models.Copilot,
relationship_column=models.FlightPlan.id,
extra_select=[models.flight_plan_has_copilot.c.token],
# filters=[
# models.flight_plan_has_copilot.c.flight_plan_id == models.FlightPlan.id,
# models.flight_plan_has_copilot.c.copilot_id == models.Copilot.id,
# ],
extra_join=[models.Copilot.flight_plans]
).load,
cache=False
)
reporting_points_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.ReportingPoint,
relationship_column=models.airport_has_reporting_point.c.airport_id,
extra_join=[models.airport_has_reporting_point]
).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,
@@ -56,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
) )
@@ -113,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
) )
@@ -150,3 +201,12 @@ photos_aircraft_dataloader = DataLoader(
).load, ).load,
cache=False cache=False
) )
flight_plan_markers_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.FlightPlanMarker,
relationship_column=models.FlightPlanMarker.flight_plan_id,
order_by=[models.FlightPlanMarker.position, models.FlightPlanMarker.id]
).load,
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)
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -16,11 +16,11 @@ class AirspaceQueries:
filters = [] filters = []
if country: if country:
filters.append(models.AirspaceItem.country == country) filters.append(models.Airspace.country == country)
if types: if types:
filters.append(models.AirspaceItem.type.in_(types)) filters.append(models.Airspace.type.in_(types))
return await BaseQueryResolver(Airspace, models.AirspaceItem).get_list( return await BaseQueryResolver(Airspace, models.Airspace).get_list(
info.context.user_id, info.context.user_id,
country=country, country=country,
filters=filters filters=filters
-2
View File
@@ -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
@@ -0,0 +1,53 @@
from typing import List, Optional
import strawberry
from strawberry.types import Info
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging
from graphql_schema.entities.helpers.detail import get_detail_filters
from graphql_schema.entities.resolvers.flight_plan import FlightPlanMutationResolver, FlightPlanQueryResolver
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
from graphql_schema.entities.types.types import FlightPlan
@strawberry.type
class FlightPlanQueries:
@strawberry.field()
@error_logging
@authenticated_user_only()
async def flight_plans(root, info: Info) -> List[FlightPlan]:
return await FlightPlanQueryResolver().get_list(info.context.user_id)
@strawberry.field()
@error_logging
@allow_public
async def flight_plan(
root,
info: Info,
id: int,
username: Optional[str] = None,
public: Optional[bool] = False
) -> FlightPlan:
filter_params = get_detail_filters(id)
if username:
filter_params['username'] = username
return await FlightPlanQueryResolver().get_one(
user_id=info.context.user_id,
only_public=public,
**filter_params
)
@strawberry.type
class FlightPlanMutation:
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def create_flight_plan(root, info, input: CreateFlightPlanInput) -> FlightPlan:
return await FlightPlanMutationResolver().create(info.context, data=input)
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def edit_flight_plan(root, info, id: int, input: EditFlightPlanInput) -> FlightPlan:
return await FlightPlanMutationResolver().update(id, input, info.context.user_id)
@@ -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
@@ -87,7 +87,7 @@ class BaseQueryResolver(BaseResolver):
class BaseMutationResolver(BaseResolver): class BaseMutationResolver(BaseResolver):
async def _get_one(self, db: AsyncSession, id: int, created_by_id: int) -> models.BaseModel: async def _get_one(self, db: AsyncSession, id: int, created_by_id: int):
query = self.query_builder.get_simple_query(created_by_id=created_by_id).filter(self.model.id == id) query = self.query_builder.get_simple_query(created_by_id=created_by_id).filter(self.model.id == id)
return (await db.scalars(query)).one() return (await db.scalars(query)).one()
+51 -105
View File
@@ -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,
@@ -0,0 +1,142 @@
import asyncio
from typing import Optional
from sqlalchemy import delete, select
from sqlalchemy.dialects.mysql import insert
from background_jobs.flight_plan_track import generate_track_for_flight_plan
from database import models
from database.models import flight_plan_has_copilot
from database.transaction import get_session
from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.resolvers.flight import handle_aircraft_save
from graphql_schema.entities.types.mutation_input import CreateFlightPlanInput, EditFlightPlanInput
from graphql_schema.entities.types.types import FlightPlan
from utils.list_utils import evenly_spaced_elements
from utils.str_utils import random_str
class FlightPlanQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
def get_query(
self,
user_id: Optional[int] = None,
only_public: Optional[bool] = False,
object_id: Optional[int] = None,
*args,
**kwargs
):
filters = {"object_id": object_id} if object_id else {}
query = super().get_query(
user_id,
**filters,
order_by=[models.FlightPlan.planned_takeoff_datetime.desc(), models.FlightPlan.id.desc()],
only_public=only_public,
only_my=not only_public
)
if kwargs.get('username'):
query = (
query.join(models.FlightPlan.created_by)
.filter(models.User.public_username == kwargs['username'])
)
return query
class FlightPlanMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(graphql_type=FlightPlan, model=models.FlightPlan)
@staticmethod
async def save_markers(db, flight_plan: models.FlightPlan, markers: list):
position = 0
for marker in markers:
if marker.type == 'poi':
assert bool(marker.point_of_interest_id)
if marker.type == 'airport':
assert bool(marker.airport_id)
await models.FlightPlanMarker.create(db, data={
"position": position,
"flight_plan_id": flight_plan.id,
"airport_id": marker.airport_id,
"point_of_interest_id": marker.point_of_interest_id,
"type": marker.type,
"name": marker.name,
"gps_latitude": marker.gps_latitude,
"gps_longitude": marker.gps_longitude
})
position += 1
@staticmethod
async def reset_plan_markers(db, flight_plan: models.FlightPlan):
await db.execute(
delete(models.FlightPlanMarker)
.filter(models.FlightPlanMarker.flight_plan_id == flight_plan.id)
)
async def create(self, context, data: CreateFlightPlanInput) -> FlightPlan:
input_data = data.to_dict()
input_data['created_by_id'] = context.user_id
async with get_session() as db:
flight_plan = await self._do_create(db, data=input_data)
await self.save_markers(db, flight_plan, data.markers)
return flight_plan
async def save_copilots(self, db, flight_plan_id: int, copilots: list, user_id: int):
await db.execute(delete(flight_plan_has_copilot).filter_by(flight_plan_id=flight_plan_id))
copilots = await asyncio.gather(*[
handle_combobox_save(db, models.Copilot, copilot, user_id) for copilot in copilots
])
for copilot_id in copilots:
await db.execute(insert(flight_plan_has_copilot).values(
flight_plan_id=flight_plan_id,
copilot_id=copilot_id,
token=random_str(64)
))
async def update(self, id: int, data: EditFlightPlanInput, user_id: int) -> FlightPlan:
input_data = data.to_dict()
async with get_session() as db:
flight_plan_model = await self._get_one(db, id=id, created_by_id=user_id)
if data.aircraft is not None:
input_data['aircraft_id'] = await handle_aircraft_save(db, user_id, data.aircraft)
if data.markers is not None:
await self.reset_plan_markers(db, flight_plan_model)
await self.save_markers(db, flight_plan_model, data.markers)
if flight_plan_model.is_default_name:
if data.markers:
markers = data.markers
else:
markers = (await db.scalars(
select(models.FlightPlanMarker)
.filter(models.FlightPlanMarker.flight_plan_id == id)
)).all()
used_markers = evenly_spaced_elements(markers, 5)
input_data['name'] = " - ".join(m.name for m in used_markers)
if data.copilots is not None:
await self.save_copilots(
db,
flight_plan_id=id,
copilots=data.copilots,
user_id=user_id
)
flight_plan = await self._do_update(db, obj=flight_plan_model, data=input_data)
await generate_track_for_flight_plan(flight_plan.id)
return flight_plan
@@ -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):
@@ -5,6 +5,7 @@ import strawberry
from strawberry.file_uploads import Upload from strawberry.file_uploads import Upload
from database import models from database import models
from graphql_schema.entities.types.base import BaseGraphqlInputType from graphql_schema.entities.types.base import BaseGraphqlInputType
from graphql_schema.entities.types.types import Aircraft
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input
@@ -14,11 +15,33 @@ class ComboboxInput:
name: str name: str
@strawberry_sqlalchemy_input(model=models.FlightPlanMarker, exclude_fields=["id", "flight_plan_id"])
class FlightPlanMarkerInput(BaseGraphqlInputType):
position: Optional[int] = 0
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class CreateCopilotInput(BaseGraphqlInputType): class CreateCopilotInput(BaseGraphqlInputType):
pass pass
@strawberry_sqlalchemy_input(model=models.FlightPlan, exclude_fields=["id", "markers"])
class CreateFlightPlanInput(BaseGraphqlInputType):
markers: list[FlightPlanMarkerInput]
def to_dict(self) -> dict:
data = super().to_dict()
data['markers'] = [marker.to_dict() for marker in self.markers]
return data
@strawberry_sqlalchemy_input(model=models.FlightPlan, exclude_fields=["id", "markers"], all_optional=True)
class EditFlightPlanInput(BaseGraphqlInputType):
markers: Optional[list[FlightPlanMarkerInput]] = None
copilots: Optional[list[ComboboxInput]] = None
aircraft: Optional[ComboboxInput] = None
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"], all_optional=True) @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"], all_optional=True)
class EditCopilotInput(BaseGraphqlInputType): class EditCopilotInput(BaseGraphqlInputType):
pass pass
@@ -115,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()
@@ -127,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'])
+105 -53
View File
@@ -1,26 +1,28 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime import math
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 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 photo_copilots_dataloader, photos_aircraft_dataloader, copilots_in_photo_dataloader, flight_plan_markers_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
@@ -29,23 +31,31 @@ class Point:
lng: float lng: float
@strawberry.type
class GPXTrack:
coordinates: List[Point] @strawberry_sqlalchemy_type(models.ReportingPoint)
speed: List[float] class ReportingPoint:
altitude: List[float] pass
magnetic_variation: List[float]
terrain_elevation: List[float]
time: List[datetime] @strawberry_sqlalchemy_type(models.Runway)
max_speed: float class Runway:
avg_speed: float pass
max_altitude: float
avg_altitude: float
@strawberry_sqlalchemy_type(models.Frequency)
class Frequency:
pass
@strawberry_sqlalchemy_type(models.Airport) @strawberry_sqlalchemy_type(models.Airport)
class Airport: class Airport:
pass reporting_points: List[ReportingPoint] = strawberry.field(
resolver=lambda root: reporting_points_dataloader.load(root.id)
)
runways: List[Runway] = strawberry.field(resolver=lambda root: runways_dataloader.load(root.id))
frequencies: List[Frequency] = strawberry.field(resolver=lambda root: frequencies_dataloader.load(root.id))
icon_url: str = strawberry.field(resolver=lambda root: airport_icon_url(root.icao_code))
@strawberry.type @strawberry.type
@@ -55,20 +65,17 @@ class Airspace:
country: str country: str
type: str type: str
lower_limit: str lower_limit: str
lower_alt_unit: str
upper_alt_unit: str
upper_limit: str upper_limit: str
center: Optional[tuple[float, float]]
radius: Optional[float]
bounds: list[tuple[float, float]] bounds: list[tuple[float, float]]
map_center: Optional[Point] = strawberry.field(
resolver=lambda root: Point(lat=root.center[0], lng=root.center[1]) if root.center else None
)
map_bounds: list[Point] = strawberry.field( map_bounds: list[Point] = strawberry.field(
resolver=lambda root: [Point(lat=point[0], lng=point[1]) for point in root.bounds] resolver=lambda root: [Point(lat=point[1], lng=point[0]) for point in root.bounds]
) )
@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)
) )
@@ -112,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):
@@ -120,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)
@@ -161,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)
) )
@@ -169,17 +168,67 @@ 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
) )
@strawberry_sqlalchemy_type(model=models.FlightPlanMarker)
class FlightPlanMarker:
pass
@strawberry.type
class FlightPlanNavlog:
bearing: Optional[int]
distance: Optional[float]
from_: FlightPlanMarker = strawberry.field(name="from")
to: Optional[FlightPlanMarker]
@strawberry_sqlalchemy_type(model=models.FlightPlan)
class FlightPlan:
async def resolve_navlog(self):
markers = await self.markers()
if len(markers) == 1:
return [FlightPlanNavlog(bearing=None, distance=None, from_=markers[0], to=None)]
navigation = []
for i in range(len(markers) - 1):
from_ = markers[i]
to = markers[i + 1]
point_from = (from_.gps_latitude, from_.gps_longitude)
point_to = (to.gps_latitude, to.gps_longitude)
navigation.append(FlightPlanNavlog(
bearing=round(math.degrees(get_bearing(point_from, point_to)) % 360),
distance=round(get_distance(point_from, point_to), 1),
from_=from_,
to=to)
)
return navigation
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
async def load_copilots(root):
return await flight_plan_copilots_dataloader.load(root.id)
markers: list[FlightPlanMarker] = strawberry.field(
resolver=lambda root: flight_plan_markers_dataloader.load(root.id)
)
navlog: Optional[list[FlightPlanNavlog]] = strawberry.field(resolver=resolve_navlog)
track: Optional[Track] = strawberry.field(resolver=lambda root: track_dataloader.load(root.track_id))
copilots: List[Copilot] = strawberry.field(resolver=load_copilots)
aircraft: Optional[Aircraft] = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id))
@strawberry_sqlalchemy_type(models.Copilot) @strawberry_sqlalchemy_type(models.Copilot)
class Copilot: class Copilot:
async def resolve_flights(root, info): async def resolve_flights(root, info):
@@ -223,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)
) )
+1 -1
View File
@@ -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
View File
@@ -3,6 +3,7 @@ from graphql_schema.entities.aircraft import AircraftMutation
from graphql_schema.entities.copilot import CopilotMutation from graphql_schema.entities.copilot import CopilotMutation
from graphql_schema.entities.event import EventMutation from graphql_schema.entities.event import EventMutation
from graphql_schema.entities.flight import FlightMutation from graphql_schema.entities.flight import FlightMutation
from graphql_schema.entities.flight_plan import FlightPlanMutation
from graphql_schema.entities.organization import OrganizationUserMutation, OrganizationMutation from graphql_schema.entities.organization import OrganizationUserMutation, OrganizationMutation
from graphql_schema.entities.photo import PhotoMutation from graphql_schema.entities.photo import PhotoMutation
from graphql_schema.entities.poi import PointOfInterestMutation from graphql_schema.entities.poi import PointOfInterestMutation
@@ -11,6 +12,7 @@ from graphql_schema.entities.user import EditUserMutation
Mutation = merge_types("Mutation", ( Mutation = merge_types("Mutation", (
AircraftMutation, AircraftMutation,
FlightMutation, FlightMutation,
FlightPlanMutation,
PhotoMutation, PhotoMutation,
PointOfInterestMutation, PointOfInterestMutation,
CopilotMutation, CopilotMutation,
+2
View File
@@ -5,6 +5,7 @@ from .entities.airspace import AirspaceQueries
from .entities.copilot import CopilotQueries from .entities.copilot import CopilotQueries
from .entities.event import EventQueries from .entities.event import EventQueries
from .entities.flight import FlightQueries from .entities.flight import FlightQueries
from .entities.flight_plan import FlightPlanQueries
from .entities.organization import OrganizationQueries from .entities.organization import OrganizationQueries
from .entities.photo import PhotoQueries from .entities.photo import PhotoQueries
from .entities.poi import PointOfInterestQueries from .entities.poi import PointOfInterestQueries
@@ -19,6 +20,7 @@ Query = merge_types('Query', (
AirportQueries, AirportQueries,
AirspaceQueries, AirspaceQueries,
FlightQueries, FlightQueries,
FlightPlanQueries,
CopilotQueries, CopilotQueries,
UserQueries, UserQueries,
PhotoQueries, PhotoQueries,
+2 -1
View File
@@ -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,
+4
View File
@@ -18,6 +18,10 @@ def get_public_url(filename: Optional[str]) -> str:
return f"{API_URL}/uploads/{filename}" if filename else None 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}")
+22
View File
@@ -0,0 +1,22 @@
from sqlalchemy import select
from database import models
from database.transaction import get_session
class FlighPlanWeather:
@staticmethod
async def get_plans() -> list:
async with get_session() as db:
markers_without_weather = (await db.execute(
select(models.FlightPlanMarker).filter(models.FlightPlanMarker.weather_info_id.is_(None))
))
flight_plan_ids = {m.flight_plan_id for m in markers_without_weather}
if not flight_plan_ids:
return []
return (await db.execute(
select(models.FlightPlan, models.FlightPlan.markers)
.filter(models.FlightPlan.id.in_(flight_plan_ids))
))
+16 -35
View File
@@ -5,67 +5,48 @@ 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}"
gpx = GPXParser(gpx_file)
coordinates = await gpx.get_coordinates()
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__":
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import dataclasses
import sys
from collections import defaultdict
from PIL import Image, ImageDraw, ImageFilter
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from database import models # noqa
from database.transaction import get_session # noqa
@dataclasses.dataclass(frozen=True)
class Runway:
heading: int
surface: str
designator: str
@dataclasses.dataclass()
class Airport:
name: str
runways: list[Runway]
async def get_airports():
async with get_session() as db:
airports_data = (await db.scalars(
select(models.Airport)
# .filter(models.Airport.icao_code.in_(["LKJH", "LKLT", "LKBE"]))
)).all()
runways_data = (await db.execute(
select(models.Runway.designator, models.Runway.heading, models.Runway.surface, models.Runway.airport_id)
.distinct()
.order_by(models.Runway.designator)
))
runways = defaultdict(set)
for rwy in runways_data:
runways[rwy.airport_id].add(rwy)
airports = []
for airport in airports_data:
airport_rwy = runways[airport.id]
if not airport_rwy:
continue
airports.append(
Airport(
name=airport.icao_code,
runways=[Runway(heading=rwy.heading, surface=rwy.surface, designator=rwy.designator) for rwy in airport_rwy]
)
)
return airports
def draw_airport(draw, icon_size):
diameter = icon_size // 2
draw.ellipse(
[(diameter // 2, diameter // 2), (icon_size - diameter // 2, icon_size - diameter // 2)],
fill="#cccccc",
outline="black"
)
return draw
# def make_rectangle(l, w, theta, offset=(0, 0)):
# c, s = math.cos(theta), math.sin(theta)
# rect_coords = [(l / 2.0, w / 2.0), (l / 2.0, -w / 2.0), (-l / 2.0, -w / 2.0), (-l / 2.0, w / 2.0)]
# return [(c * x - s * y + offset[0], s * x + c * y + offset[1]) for (x, y) in rect_coords]
#
# def draw_runway(airport_img, runway: Runway, size):
# if runway.heading > 180:
# return
#
# rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
#
# draw = ImageDraw.Draw(rwy)
#
# width = 60
# center = size // 2 - width // 2
#
# colors = {
# "asphalt": "#333333",
# "concrete": "#dddddd",
# "grass": "#00ff00"
# }
#
# draw.rectangle((center, 60, center + width, size-60), fill=colors[runway.surface])
#
# font = ImageFont.truetype('/app/static/Calibri.ttf', 60)
# draw.text((center, size - width), runway.designator, font=font, fill="black")
# rwy = rwy.rotate(-1 * runway.heading, expand=False)
#
# airport_img.paste(rwy, mask=rwy)
# print(f"Kreslim RWY {runway}")
# length = size * 0.9
# width = 40
# vertices = make_rectangle(length, width, theta=runway.heading, offset=(250, 250))
# draw.polygon(vertices, fill="#dddddd", outline="black")
# return draw
def add_edge_border(image, border_size=5, border_color=(255, 0, 0, 255)):
mask = image.split()[3]
edge_mask = mask.filter(ImageFilter.FIND_EDGES)
border_image = Image.new("RGBA", image.size, (0, 0, 0, 0))
border_image.paste(border_color, mask=edge_mask)
for _ in range(border_size - 1):
edge_mask = edge_mask.filter(ImageFilter.MaxFilter(3))
border_image.paste(border_color, mask=edge_mask)
bordered_image = Image.alpha_composite(border_image, image)
return bordered_image
def draw_runways(airport_img, runways: list[Runway], size: int):
rwy_width = 60
rwy_margin = 10
center = size // 2
all_runways_width = len(runways) * rwy_width + (len(runways) - 1) * rwy_margin
start = center - all_runways_width // 2
colors = {
"asphalt": "#777777",
"concrete": "#cccccc",
"grass": "#00aa00"
}
rwy = Image.new("RGBA", (size, size), (0, 0, 0, 0))
heading_sum = 0
draw = ImageDraw.Draw(rwy)
for runway in runways:
top_left = (start, 80)
bottom_right = (start + rwy_width, size - 80)
draw.rectangle((*top_left, *bottom_right), fill=colors[runway.surface])
# draw.text((start, size - 60), runway.designator, font=font, fill="black")
start += rwy_width + rwy_margin
heading_sum += runway.heading
rwy = rwy.rotate(-1 * heading_sum // len(runways), expand=False)
airport_img.paste(rwy, mask=rwy)
async def generate_icon(airport: Airport):
icon_size = 500
# creating new Image object
img = Image.new("RGBA", (icon_size, icon_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if len(airport.name) == 4:
draw = draw_airport(draw, icon_size)
parallel_runways = defaultdict(list)
for runway in airport.runways:
key = runway.designator[:2]
parallel_runways[key].append(runway)
for designator, runways in parallel_runways.items():
draw_runways(img, runways, icon_size)
img = add_edge_border(img, border_size=10, border_color=(0, 0, 255))
img = img.resize((40, 40), resample=Image.LANCZOS)
img.save(f'/app/static/airports/{airport.name}.png', 'PNG')
# img.save(f'/app/static/airports/{airport.name}.webp', 'WEBP')
print(airport.name, [(rwy.designator, rwy.surface) for rwy in airport.runways])
async def generate_icons():
airports = await get_airports()
for airport in airports:
await generate_icon(airport)
# return
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_icons())
+15
View File
@@ -0,0 +1,15 @@
import asyncio
import sys
sys.path.insert(0, "/app/src")
from background_jobs.flight_plan_track import generate_track_for_flight_plan
async def run_all():
await generate_track_for_flight_plan(11)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(run_all())
+26
View File
@@ -0,0 +1,26 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, "/app/src")
from utils.flight_track_helpers import save_track_from_gpx_to_db
from database import models
from database.transaction import get_session
async def migrate_gpx():
async with get_session() as db:
flights = (
await db.execute(
select(models.Flight.id, models.Flight.gpx_track_filename)
.filter(models.Flight.gpx_track_filename.is_not(None)))
).all()
flight_tracks = {f.id: f.gpx_track_filename for f in flights}
for id, gpx_filename in flight_tracks.items():
await save_track_from_gpx_to_db(flight_id=id, gpx_filename=gpx_filename)
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(migrate_gpx())
+49
View File
@@ -0,0 +1,49 @@
import asyncio
import sys
from sqlalchemy import select
sys.path.insert(0, '/app/src')
from background_jobs.flight_title_photo import add_circular_avatar # noqa
from database import models # noqa
from database.transaction import get_session # noqa
async def flight_title_photos():
async with get_session() as db:
flights = (await db.execute(
select(models.Photo, models.Flight, models.User)
.join(models.Flight.title_photo)
.join(models.Flight.created_by)
)).all()
for photo, flight, pilot in flights:
await add_circular_avatar(
base_image_path=f'/app/uploads/photos/{flight.id}/{photo.filename}.{photo.filename_extension}',
avatar_image_path=f'/app/uploads/profile/{pilot.id}/{pilot.avatar_image_filename}',
output_image_path=f'/app/uploads/photos/{flight.id}/title_photo.jpg',
)
async def user_title_photos():
async with get_session() as db:
users = (await db.execute(
select(models.User.id, models.User.title_image_filename, models.User.avatar_image_filename)
.filter(models.User.title_image_filename != '')
)).all()
for user_id, title_image, avatar_image in users:
await add_circular_avatar(
base_image_path=f"/app/uploads/profile/{user_id}/{title_image}",
avatar_image_path=f"/app/uploads/profile/{user_id}/{avatar_image}",
output_image_path=f'/app/uploads/profile/{user_id}/title_photo.jpg'
)
async def generate_images():
await user_title_photos()
await flight_title_photos()
if __name__ == "__main__":
loop = asyncio.get_event_loop()
loop.run_until_complete(generate_images())
+31
View File
@@ -1,4 +1,7 @@
import os import 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
+125
View File
@@ -0,0 +1,125 @@
import asyncio
from typing import Optional
from sqlalchemy import delete, func, select, text
from sqlalchemy import delete
from strawberry.file_uploads import Upload
from database import models
from database.transaction import get_session
from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import handle_file_upload, delete_file
def get_bounds(coordinates: list[dict[str, float]]) -> list[tuple[float, float]]:
latitudes = [c['lat'] for c in coordinates]
longitudes = [c['lng'] for c in coordinates]
return [
(min(latitudes), min(longitudes)),
(min(latitudes), max(longitudes)),
(max(latitudes), min(longitudes)),
(max(latitudes), max(latitudes))
]
async def get_airport_id_by_gps(gps_lat: float, gps_lng: float) -> Optional[int]:
async with get_session() as db:
query = (
select(models.Airport, func.coalesce(6371 * func.acos(
func.cos(func.radians(gps_lat)) *
func.cos(func.radians(models.Airport.gps_latitude)) *
func.cos(func.radians(models.Airport.gps_longitude) - func.radians(gps_lng)) +
func.sin(func.radians(gps_lat)) *
func.sin(func.radians(models.Airport.gps_latitude))
), 9999).label("distance"))
.filter(models.Airport.use_in_gpx_guess.is_(True))
.order_by("distance")
.having(text("distance < 1"))
.limit(1)
)
data = (await db.execute(query)).one_or_none()
if data:
airport, distance = data
return airport.id
return None
async def extract_basic_flight_info_from_gpx(gpx_filename: str) -> dict:
data = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
times, coordinates = await asyncio.gather(
data.get_times(),
data.get_coordinates()
)
takeoff_airport_id, landing_airport_id = await asyncio.gather(
get_airport_id_by_gps(coordinates[0]['lat'], coordinates[0]['lng']),
get_airport_id_by_gps(coordinates[-1]['lat'], coordinates[-1]['lng']),
)
return {
"takeoff_airport_id": takeoff_airport_id,
"landing_airport_id": landing_airport_id,
"takeoff_datetime": times[0],
"landing_datetime": times[-1],
}
async def handle_upload_gpx(gpx_track: Upload, flight_id: int):
filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH)
await save_track_from_gpx_to_db(gpx_filename=filename, flight_id=flight_id)
return filename
async def save_track_from_gpx_to_db(gpx_filename: str, flight_id: int | None = None):
try:
gpx_parser = GPXParser(file=f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
except OSError as e:
print(f"Cannot process {gpx_filename}: {e}")
return
altitudes = await gpx_parser.get_altitude()
terrain_elevations = await gpx_parser.get_terrain_elevation()
coordinates = await gpx_parser.get_coordinates()
speeds = await gpx_parser.get_speed()
magnetic_variations = await gpx_parser.get_magnetic_variation()
times = await gpx_parser.get_times()
track_data = {
"bounds": get_bounds(coordinates),
"min_speed": await gpx_parser.get_min_speed(),
"avg_speed": await gpx_parser.get_avg_speed(),
"max_speed": await gpx_parser.get_max_speed(),
"total_duration": (await gpx_parser.get_total_duration()).seconds,
"max_altitude": await gpx_parser.get_max_altitude(),
"avg_altitude": await gpx_parser.get_avg_altitude(),
}
async with get_session() as db:
flight = await models.Flight.get_one(db, id=flight_id)
if flight.track_id is None:
track = await models.Track.create(db, {
**track_data,
"created_by_id": flight.created_by_id
})
flight.track_id = track.id
else:
await db.execute(delete(models.TrackPoint).filter(models.TrackPoint.track_id == flight.track_id))
track = await models.Track.get_one(db, id=flight.track_id)
await models.Track.update(db, data=track_data, obj=track)
for i in range(len(coordinates)):
await models.TrackPoint.create(db, {
"track_id": track.id,
"altitude": altitudes[i] if i < len(altitudes) else None,
"magnetic_variation": magnetic_variations[i] if i < len(magnetic_variations) else None,
"terrain_elevation": terrain_elevations[i] if i < len(terrain_elevations) else None,
"speed": speeds[i] if i < len(speeds) else None,
"gps_latitude": coordinates[i]['lat'],
"gps_longitude": coordinates[i]['lng'],
"timestamp": times[i]
})
+57
View File
@@ -1,6 +1,63 @@
from typing import Tuple 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
+21
View File
@@ -0,0 +1,21 @@
import typing
from itertools import islice
def chunks(items: list | dict, n: int) -> typing.Generator:
"""Yield successive n-sized chunks from lst."""
if isinstance(items, list):
for i in range(0, len(items), n):
yield items[i:i + n]
elif isinstance(items, dict):
it = iter(items)
for i in range(0, len(items), n):
yield {k: items[k] for k in islice(it, n)}
def evenly_spaced_elements(lst: list, count: int) -> list:
if count > len(lst):
return lst
interval = (len(lst) - 1) / (count - 1) if count > 1 else 0
return [lst[int(round(i * interval))] for i in range(count)]
-33
View File
@@ -1,33 +0,0 @@
import os
import re
import uuid
from strawberry.file_uploads import Upload
from utils.file import check_directories
async def handle_file_upload(
file: Upload,
path: str,
filename_maxlength: int = 64,
uid_prefix: bool = True,
overwrite: bool = True
) -> str:
check_directories(path)
prefix = f"{uuid.uuid4()}-" if uid_prefix else ""
filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:]
# sanitize filename
filename = re.sub(r'[^\w_. -]', '', filename).replace(" ", "-")
target_path = f"{path}/{filename}"
if os.path.exists(target_path) and not overwrite:
raise FileExistsError(f"File {filename} already exists")
content = await file.read()
image = open(target_path, "wb")
image.write(content)
image.close()
return filename
File diff suppressed because it is too large Load Diff
View File
-44
View File
@@ -1,44 +0,0 @@
import os
import sys
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
ROOT_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT_DIR / "src"))
from logger import log
def pytest_sessionstart():
os.environ['MYSQL_HOST'] = 'localhost'
os.environ['MYSQL_USER'] = 'root'
os.environ['MYSQL_PASSWORD'] = 'root'
@pytest.fixture(scope="function")
async def db():
# from database.transaction import get_session
#
# async with get_session() as db:
# yield db
# await db.rollback()
import database
from database.config import create_db_engine
test_engine = create_db_engine()
test_async_session = async_sessionmaker(test_engine, expire_on_commit=True, class_=AsyncSession)
database.config.engine = test_engine
database.async_session = test_async_session
async with test_async_session() as session:
async with session.begin():
try:
yield session
await session.rollback()
except Exception as e:
log.exception("Error", exc_info=e)
await session.rollback()
-16
View File
@@ -1,16 +0,0 @@
from typing import Optional
from database import models
from utils.str_utils import random_str
async def create_pilot(db, data: Optional[dict] = None) -> models.User:
if not data:
data = {}
return await models.User.create(db, {
"email": data.pop("email", f"{random_str(5)}@{random_str(10)}.cz"),
"name": data.pop("name", "Pepa Tester"),
"description": data.pop("description", ""),
"password_hashed": data.pop("password_hashed", "***")
})
-49
View File
@@ -1,49 +0,0 @@
from collections import namedtuple
from typing import Optional
import pytest
from database import models
from graphql_schema.entities.resolvers.aircraft import AircraftQueryResolver
from tests.factories import create_pilot
from utils.str_utils import random_str
AircraftData = namedtuple('AircraftData', ["aircraft", "user"])
async def create_aircraft(db, data: Optional[dict] = None) -> AircraftData:
if not data:
data = {}
if not data.get("created_by_id"):
pilot = await create_pilot(db)
data["created_by_id"] = pilot.id
else:
pilot = await models.User.get_one(db, id=data['created_by_id'])
return AircraftData(
aircraft=await models.Aircraft.create(
db,
{
"call_sign": data.pop("call_sign", f"OK-{random_str(4)}"),
"is_public": data.pop("is_public", False),
"created_by_id": data.pop('created_by_id'),
"seats": data.pop("seats", 2),
**data
}
),
user=pilot
)
async def test_private_aircraft(db):
aircraft_data = await create_aircraft(db)
resolver = AircraftQueryResolver()
params =[
{"id": aircraft_data.aircraft.id, "created_by_id": 234}
]
for param in params:
aircraft = await resolver.get_one(**param)
assert aircraft.id == aircraft_data.aircraft.id
-3
View File
@@ -1,3 +0,0 @@
async def test_simple_query(db):
# query_builder = QueryBuilder()
pass
View File