55 Commits
Author SHA1 Message Date
Michal Kváček 10b7163f23 init testu 2024-04-02 06:13:23 +02:00
Michal Kváček ffb8d07ebe fix nacitani letadla podle volacky 2024-03-29 10:40:52 +01:00
Michal Kváček fd0aef83b1 fix nacitani fotek na verejne casti 2024-03-25 22:35:11 +01:00
Michal Kváček 66d9877552 Fix nacitani detailu letadla 2024-03-25 19:14:03 +01:00
Michal Kváček 6a7d51efc5 Rychlost v GPX muze byt float 2024-03-23 20:28:11 +01:00
Michal Kváček ed35de96c9 Parsovani vzdusneho prostoru 2024-03-21 16:40:23 +01:00
Michal Kváček 7c81069e96 Fix nacitani eventu 2024-03-07 07:07:33 +01:00
Michal Kváček 400ad8b75a Moznost nahravat fotky copilotem 2024-03-07 06:45:00 +01:00
Michal Kváček c0ae73440d Bugfixing 2024-02-28 09:41:46 +01:00
Michal Kváček 1f8b338297 Pridani sitemap, fix flake 2024-02-26 09:44:40 +01:00
Michal Kváček ff32c36a94 Pridani samplovani dat - neni potreba vracet polohu a rychlost kazdych 10s 2024-02-22 08:17:40 +01:00
Michal Kváček e28a6fce57 Uprava queries a dotazovani se pres base query builder, fix nahledu fotky pro editor, cache na fotky 2024-02-13 22:59:51 +01:00
Michal Kváček 51316c492b Fix nacitani detailu udalosti 2024-02-12 06:33:09 +01:00
Michal Kváček c26eb97288 Reset hesla, doplneni mailu 2024-02-08 16:26:12 +01:00
Michal Kváček 03c9f2d189 Vyhledavani v POI 2024-02-01 08:32:10 +01:00
Michal Kváček 77b9552a9d Zakladni mailing 2024-02-01 07:22:14 +01:00
Michal Kváček cc95197682 Odstraneni generovanych HTML sablon mailu 2024-01-20 23:21:26 +01:00
Michal Kváček 57aad05359 Zakladni nastrel emailu 2024-01-20 23:20:37 +01:00
Michal Kváček dfb96e768e Aktualizace zavislosti, pridani debrief poznamek 2024-01-20 22:09:05 +01:00
Michal Kváček 0e890dc201 Odstraneni zbytecnych importu 2024-01-20 15:39:49 +01:00
Michal Kváček 3c757df7f1 Bugfixing 2024-01-18 06:53:01 +01:00
Michal Kváček dabed327dd Slugy, webp, hromada zmen 2024-01-13 23:29:24 +01:00
Michal Kváček 3b1fd0351a Fix razeni fotek 2023-12-19 20:35:06 +01:00
Michal Kváček 3edc1c853f Fix nacitani letu k eventu 2023-12-12 23:54:28 +01:00
Michal Kváček f4d709d621 Strankovani 2023-12-12 23:41:44 +01:00
Michal Kváček f535d2866c Uprava razeni fotek, drobnosti 2023-12-10 22:11:44 +01:00
Michal Kváček 89f5b8bb0c Upravy v dotazovani na verejna data 2023-12-05 12:15:54 +01:00
Michal Kváček 3167ea2f8a Hromada uprav kvuli ssr renderu a verejne casti 2023-12-04 22:45:44 +01:00
Michal Kváček ecb37fb961 Povoleni CORS i z dev SSR domeny, dataloader prevadi list IDcek na set 2023-11-23 16:40:42 +01:00
Michal Kváček 108a731608 Fix vytvareni letu s novym letadlem 2023-11-18 23:41:43 +01:00
Michal Kváček 19c1ff1846 Bugfixes 2023-11-09 06:17:01 +01:00
Michal Kváček bd5b84c9e7 Error logging, uprava dotazovani na seznam letu u kopilota 2023-11-07 17:45:26 +01:00
Michal Kváček e3ab6ba7f0 Pridani kontroly existence cesty pro zapsani fotky, pridano razeni letu u kopilota 2023-11-06 21:41:44 +01:00
Michal Kváček e8aa678555 Dotazovani na detail kopilota jen po prihlaseni nebo jen s username 2023-11-02 16:29:02 +01:00
Michal Kváček f2e2c1a1ab Fix flake 2023-11-02 09:25:23 +01:00
Michal Kváček ebf2995d51 Filtrovani copilotu 2023-11-02 09:12:01 +01:00
Michal Kváček 52ddf6502a Refaktoring photo endpointu 2023-11-02 09:03:35 +01:00
Michal Kváček 0de73a5ec2 Pridani sentry, fix mazani upravenych fotek 2023-10-31 17:30:29 +01:00
Michal Kváček 3223d2ac00 Pridani editace fotek 2023-10-27 22:30:59 +02:00
Michal Kváček 322e61bce3 Fix otaceni fotek, drobny refaktoring 2023-10-15 14:24:57 +02:00
Michal Kváček bc258efa3a Refaktoring 2023-10-14 22:42:51 +02:00
Michal Kváček 7f7f79dad6 Refaktoring a bugfixing 2023-10-13 23:24:53 +02:00
Michal Kváček ce2c023eaf Do sloupce photo.exposed_at ukladat datetime_original, ne datum vyvolani rawu 2023-10-09 15:07:32 +02:00
Michal Kváček 86fe3949f6 Presun nacitani doby letu do dataloaderu 2023-10-09 10:24:53 +02:00
Michal Kváček b355d0b439 Refaktoring, pridani letiste pro mezipristani 2023-10-07 21:36:56 +02:00
Michal Kváček 2422f26f10 Fix loginu 2023-09-26 06:56:38 +02:00
Michal Kváček 3f03021f5c Refaktoring 2023-09-26 06:49:15 +02:00
Michal Kváček dc49cd347c Pouziti base resolveru u letiste 2023-09-25 10:07:56 +02:00
Michal Kváček d820b92964 Refaktoring resolveru 2023-09-25 10:04:03 +02:00
Michal Kváček 22a28ae70e Uprava razeni letu 2023-09-25 06:26:16 +02:00
Michal Kváček 2c53a8ffa3 Otaceni fotek 2023-09-21 09:39:39 +02:00
Michal Kváček f50c9660c7 Fix nacitani seznamu letu 2023-09-20 14:14:53 +02:00
Michal Kváček 037c7daef6 Sprava eventu a organizaci, bugfixing a drobny refaktoring 2023-09-20 13:26:34 +02:00
Michal Kváček 1760dba99d Uprava prace s DB, refaktoring 2023-09-13 10:04:06 +02:00
Michal Kváček 64cb544e2d Pridani skriptu pro pridani elevace, uprava prace s transakcemi 2023-09-12 11:00:36 +02:00
122 changed files with 12176 additions and 1547 deletions
+24
View File
@@ -0,0 +1,24 @@
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
# 4 space indentation
[*.{py,java,r,R}]
indent_style = space
indent_size = 4
# 2 space indentation
[*.{js,json,yml,yaml,html}]
indent_style = space
indent_size = 2
[*.{md,Rmd,rst}]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2
+5
View File
@@ -7,3 +7,8 @@ MYSQL_DATABASE: "poletani_cz"
MYSQL_USER: "mysql_user" MYSQL_USER: "mysql_user"
MYSQL_PASSWORD: "****" MYSQL_PASSWORD: "****"
APP_ENV: "production" APP_ENV: "production"
SMTP_HOST: ""
SMTP_PORT: ""
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
+2
View File
@@ -8,3 +8,5 @@ __pycache__/
# app specific files and dirs # app specific files and dirs
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
src/emails/templates/html/*
.env
+12 -1
View File
@@ -1,8 +1,18 @@
FROM python:3.11-alpine FROM node:20-alpine AS email_builder
RUN npm install -g mjml
RUN mkdir -p /app/templates/html
COPY ./src/emails/templates/mjml /app/templates
RUN mjml /app/templates/* -o /app/templates/html/
FROM python:3.11-alpine AS app
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 # protoze xfs uzivatel ma id=33
RUN deluser xfs RUN deluser xfs
@@ -10,6 +20,7 @@ RUN deluser xfs
RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data 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/
# required packages for pip (becasue of yarl lib) # required packages for pip (becasue of yarl lib)
ENV INSTALL_PACKAGES build-base linux-headers ENV INSTALL_PACKAGES build-base linux-headers
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+3
View File
@@ -0,0 +1,3 @@
# Polétání.cz
@@ -0,0 +1,46 @@
"""add terrain elevation to photos and event table
Revision ID: 6e5cc5123a2b
Revises: 76752a74a304
Create Date: 2023-09-05 09:54:45.509461
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '6e5cc5123a2b'
down_revision = '76752a74a304'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('event',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('description', sa.Text(), 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')
)
op.add_column('flight', sa.Column('event_id', sa.Integer(), nullable=True))
op.add_column('flight', sa.Column('has_terrain_elevation', sa.Boolean(), nullable=False))
op.create_foreign_key(None, 'flight', 'event', ['event_id'], ['id'])
op.add_column('photo', sa.Column('terrain_elevation', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('photo', 'terrain_elevation')
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'has_terrain_elevation')
op.drop_column('flight', 'event_id')
op.drop_table('event')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""make gps in airport nullable
Revision ID: a09857ec9721
Revises: 6e5cc5123a2b
Create Date: 2023-09-13 09:32:06.444179
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'a09857ec9721'
down_revision = '6e5cc5123a2b'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('airport', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=True)
op.alter_column('airport', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('airport', 'gps_longitude',
existing_type=mysql.FLOAT(),
nullable=False)
op.alter_column('airport', 'gps_latitude',
existing_type=mysql.FLOAT(),
nullable=False)
# ### end Alembic commands ###
@@ -0,0 +1,50 @@
"""add from/to to event, organization to event
Revision ID: 39a62618eacb
Revises: a09857ec9721
Create Date: 2023-09-20 11:53:00.566736
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '39a62618eacb'
down_revision = 'a09857ec9721'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('aircraft_notes')
op.add_column('event', sa.Column('event_from', sa.DateTime(), nullable=True))
op.add_column('event', sa.Column('event_to', sa.DateTime(), nullable=True))
op.add_column('event', sa.Column('organization_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'event', 'organization', ['organization_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'event', type_='foreignkey')
op.drop_column('event', 'organization_id')
op.drop_column('event', 'event_to')
op.drop_column('event', 'event_from')
op.create_table('aircraft_notes',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('aircraft_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('name', mysql.VARCHAR(length=128), nullable=False),
sa.Column('description', mysql.TEXT(), nullable=False),
sa.Column('is_public', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, 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(['aircraft_id'], ['aircraft.id'], name='aircraft_notes_ibfk_1'),
sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], name='aircraft_notes_ibfk_2'),
sa.PrimaryKeyConstraint('id'),
mysql_collate='utf8mb4_general_ci',
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""rename from/to in events
Revision ID: 8b0c020dc0c4
Revises: 39a62618eacb
Create Date: 2023-09-22 08:53:20.901142
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '8b0c020dc0c4'
down_revision = '39a62618eacb'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event', sa.Column('date_from', sa.DateTime(), nullable=True))
op.add_column('event', sa.Column('date_to', sa.DateTime(), nullable=True))
op.drop_column('event', 'event_from')
op.drop_column('event', 'event_to')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('event', sa.Column('event_to', mysql.DATETIME(), nullable=True))
op.add_column('event', sa.Column('event_from', mysql.DATETIME(), nullable=True))
op.drop_column('event', 'date_to')
op.drop_column('event', 'date_from')
# ### end Alembic commands ###
@@ -0,0 +1,38 @@
"""add airports to track
Revision ID: 956d295689bf
Revises: 8b0c020dc0c4
Create Date: 2023-09-28 22:43:52.286599
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '956d295689bf'
down_revision = '8b0c020dc0c4'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_track', sa.Column('airport_id', sa.Integer(), nullable=True))
op.add_column('flight_track', sa.Column('landing_duration', sa.Integer(), nullable=True))
op.alter_column('flight_track', 'point_of_interest_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.create_foreign_key(None, 'flight_track', 'airport', ['airport_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'flight_track', type_='foreignkey')
op.alter_column('flight_track', 'point_of_interest_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_column('flight_track', 'landing_duration')
op.drop_column('flight_track', 'airport_id')
# ### end Alembic commands ###
@@ -0,0 +1,39 @@
"""Add photo adjustment
Revision ID: 2acb6fa61028
Revises: 956d295689bf
Create Date: 2023-10-15 22:33:04.911765
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2acb6fa61028'
down_revision = '956d295689bf'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('photo_adjustment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('photo_id', sa.Integer(), nullable=False),
sa.Column('rotate', sa.Float(), nullable=True),
sa.Column('contrast', sa.Float(), nullable=True),
sa.Column('brightness', sa.Float(), nullable=True),
sa.Column('saturation', sa.Float(), nullable=True),
sa.Column('sharpness', sa.Float(), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['photo_id'], ['photo.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('photo_adjustment')
# ### end Alembic commands ###
@@ -0,0 +1,34 @@
"""add crop info
Revision ID: c3aef4ae2669
Revises: 2acb6fa61028
Create Date: 2023-10-20 23:53:21.772545
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c3aef4ae2669'
down_revision = '2acb6fa61028'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('photo_adjustment', sa.Column('crop_left', sa.Float(), nullable=True))
op.add_column('photo_adjustment', sa.Column('crop_top', sa.Float(), nullable=True))
op.add_column('photo_adjustment', sa.Column('crop_width', sa.Float(), nullable=True))
op.add_column('photo_adjustment', sa.Column('crop_height', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('photo_adjustment', 'crop_height')
op.drop_column('photo_adjustment', 'crop_width')
op.drop_column('photo_adjustment', 'crop_top')
op.drop_column('photo_adjustment', 'crop_left')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""add size to photos
Revision ID: 4996ad8bc231
Revises: c3aef4ae2669
Create Date: 2023-10-24 16:32:10.826763
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4996ad8bc231'
down_revision = 'c3aef4ae2669'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('photo', sa.Column('width', sa.Integer(), nullable=False))
op.add_column('photo', sa.Column('height', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('photo', 'height')
op.drop_column('photo', 'width')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""on delete cascade u photo adjustment
Revision ID: d42172840f6c
Revises: 4996ad8bc231
Create Date: 2023-10-30 16:30:57.118694
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd42172840f6c'
down_revision = '4996ad8bc231'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('photo_adjustment_ibfk_1', 'photo_adjustment', type_='foreignkey')
op.create_foreign_key(None, 'photo_adjustment', 'photo', ['photo_id'], ['id'], ondelete='CASCADE')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'photo_adjustment', type_='foreignkey')
op.create_foreign_key('photo_adjustment_ibfk_1', 'photo_adjustment', 'photo', ['photo_id'], ['id'])
# ### end Alembic commands ###
@@ -0,0 +1,50 @@
"""add photos to aircraft, poi and copilots
Revision ID: 91a9608b509d
Revises: d42172840f6c
Create Date: 2023-11-28 16:32:56.725312
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '91a9608b509d'
down_revision = 'd42172840f6c'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('copilot_has_photo',
sa.Column('copilot_id', sa.Integer(), nullable=False),
sa.Column('photo_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['copilot_id'], ['copilot.id'], ),
sa.ForeignKeyConstraint(['photo_id'], ['photo.id'], ),
sa.PrimaryKeyConstraint('copilot_id', 'photo_id')
)
op.add_column('aircraft', sa.Column('title_photo_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'aircraft', 'photo', ['title_photo_id'], ['id'])
op.add_column('flight', sa.Column('title_photo_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'flight', 'photo', ['title_photo_id'], ['id'])
op.add_column('photo', sa.Column('aircraft_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'photo', 'aircraft', ['aircraft_id'], ['id'])
op.add_column('point_of_interest', sa.Column('title_photo_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'point_of_interest', 'photo', ['title_photo_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'point_of_interest', type_='foreignkey')
op.drop_column('point_of_interest', 'title_photo_id')
op.drop_constraint(None, 'photo', type_='foreignkey')
op.drop_column('photo', 'aircraft_id')
op.drop_constraint(None, 'flight', type_='foreignkey')
op.drop_column('flight', 'title_photo_id')
op.drop_constraint(None, 'aircraft', type_='foreignkey')
op.drop_column('aircraft', 'title_photo_id')
op.drop_table('copilot_has_photo')
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add is_public to aircraft
Revision ID: e5682ac4355c
Revises: 91a9608b509d
Create Date: 2023-12-02 12:14:25.055308
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'e5682ac4355c'
down_revision = '91a9608b509d'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('aircraft', sa.Column('is_public', sa.Boolean(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('aircraft', 'is_public')
# ### end Alembic commands ###
@@ -0,0 +1,30 @@
"""add title photo to copilot
Revision ID: 022bf6fbcd47
Revises: e5682ac4355c
Create Date: 2023-12-02 13:05:46.267006
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '022bf6fbcd47'
down_revision = 'e5682ac4355c'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('copilot', sa.Column('title_photo_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'copilot', 'photo', ['title_photo_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'copilot', type_='foreignkey')
op.drop_column('copilot', 'title_photo_id')
# ### end Alembic commands ###
@@ -0,0 +1,60 @@
"""pridani slugu, odstraneni deprecated sloupcu, pridani defaultu ke jmenu a popisumm
Revision ID: 6648fb80dd0e
Revises: 022bf6fbcd47
Create Date: 2024-01-03 06:17:13.457060
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '6648fb80dd0e'
down_revision = '022bf6fbcd47'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('aircraft', 'photo_filename')
op.add_column('airport', sa.Column('airport_type', sa.Enum('airport', 'ull', 'heliport'), server_default='airport', nullable=False))
op.add_column('airport', sa.Column('use_in_gpx_guess', sa.Boolean(), server_default='1', nullable=False))
op.add_column('copilot', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False))
op.add_column('copilot', sa.Column('is_public', sa.Boolean(), server_default='0', nullable=False))
op.add_column('event', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False))
op.add_column('flight', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False))
op.alter_column('flight', 'takeoff_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.alter_column('flight', 'landing_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.add_column('photo', sa.Column('filename_extension', sa.String(length=4), nullable=False))
op.add_column('photo', sa.Column('cache_key', sa.String(length=128), nullable=True))
op.drop_column('photo', 'is_flight_cover')
op.add_column('point_of_interest', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('point_of_interest', 'url_slug')
op.add_column('photo', sa.Column('is_flight_cover', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False))
op.drop_column('photo', 'cache_key')
op.drop_column('photo', 'filename_extension')
op.alter_column('flight', 'landing_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.alter_column('flight', 'takeoff_airport_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.drop_column('flight', 'url_slug')
op.drop_column('event', 'url_slug')
op.drop_column('copilot', 'is_public')
op.drop_column('copilot', 'url_slug')
op.drop_column('airport', 'use_in_gpx_guess')
op.drop_column('airport', 'airport_type')
op.add_column('aircraft', sa.Column('photo_filename', mysql.VARCHAR(length=128), nullable=True))
# ### end Alembic commands ###
@@ -0,0 +1,28 @@
"""add flight.debriefind_notes field
Revision ID: 39ebf5a1ab7e
Revises: 6648fb80dd0e
Create Date: 2024-01-20 16:22:00.487226
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '39ebf5a1ab7e'
down_revision = '6648fb80dd0e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight', sa.Column('debriefing_notes', sa.Text(), server_default='', nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('flight', 'debriefing_notes')
# ### end Alembic commands ###
@@ -0,0 +1,35 @@
"""add password_reset_token table
Revision ID: a410455fb3bb
Revises: 39ebf5a1ab7e
Create Date: 2024-02-08 07:59:02.066212
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'a410455fb3bb'
down_revision = '39ebf5a1ab7e'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('password_reset_token',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=64), nullable=False),
sa.Column('valid_until', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('password_reset_token')
# ### end Alembic commands ###
@@ -0,0 +1,53 @@
"""add flight_has_copilot.token
Revision ID: 7dd139549851
Revises: a410455fb3bb
Create Date: 2024-03-02 15:02:26.594830
"""
import random
import string
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '7dd139549851'
down_revision = 'a410455fb3bb'
branch_labels = None
depends_on = None
def rand_str():
alphabet = string.ascii_letters + string.digits
return ''.join([random.choice(alphabet) for _ in range(64)])
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_has_copilot', sa.Column('token', sa.String(length=64), nullable=True))
conn = op.get_bind()
data = conn.execute(text("SELECT flight_id, copilot_id FROM flight_has_copilot"))
for flight_id, copilot_id in data:
print(flight_id, copilot_id)
conn.execute(text(
f"UPDATE flight_has_copilot SET token='{rand_str()}'"
f" WHERE flight_id={flight_id} AND copilot_id={copilot_id}"
))
op.create_unique_constraint(None, 'flight_has_copilot', ['token'])
op.add_column('photo', sa.Column('created_by_copilot_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'photo', 'copilot', ['created_by_copilot_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('photo_ibfk_5', 'photo', type_='foreignkey')
op.drop_column('photo', 'created_by_copilot_id')
op.drop_constraint('token', 'flight_has_copilot', type_='unique')
op.drop_column('flight_has_copilot', 'token')
# ### end Alembic commands ###
@@ -0,0 +1,44 @@
"""Add airspace_item table
Revision ID: aba4c3876b28
Revises: 7dd139549851
Create Date: 2024-03-18 10:05:57.516697
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = 'aba4c3876b28'
down_revision = '7dd139549851'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('airspace_item',
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_item_country'), 'airspace_item', ['country'], unique=False)
op.create_index(op.f('ix_airspace_item_type'), 'airspace_item', ['type'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_airspace_item_type'), table_name='airspace_item')
op.drop_index(op.f('ix_airspace_item_country'), table_name='airspace_item')
op.drop_table('airspace_item')
# ### end Alembic commands ###
+17 -4
View File
@@ -2,8 +2,16 @@ import csv
def deg_to_dec(val: str): def deg_to_dec(val: str):
val = float(val[:-1]) / 100
deg = int(val)
frac = val % 1
# https://www.pgc.umn.edu/apps/convert/ N47 17.6 E012 47.3
print(frac, frac * 60, frac * 3600)
return deg + (frac * 60)
val = val[:-1].lstrip("0")
# h_m, s = val.split(".") # h_m, s = val.split(".")
# h_m = h_m.strip("0") # h_m = h_m.strip("0")
# h = int(h_m[0:2]) # h = int(h_m[0:2])
@@ -16,15 +24,20 @@ def deg_to_dec(val: str):
# print("--konec-----------------------------------") # print("--konec-----------------------------------")
# return h + (m / 60.0) + (int(s) / 3600.0) # return h + (m / 60.0) + (int(s) / 3600.0)
return len(val or "") # return val
# return len(val or "")
# ZDAKOV: @49.504378,14.1808905 # ZDAKOV: @49.504378,14.1808905
# 4930.250N / 100 -> cele cislo stupne, desetinne prevest do sedesatkove soustavy
with open("./poi.csv") as f: with open("./poi.csv") as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
for row in reader: for row in reader:
# if row['name'] != 'ZDAKOV': if row['name'] != 'ZDAKOV':
# continue continue
print(row['name'], row['lat'], row['lon'], deg_to_dec(row['lat']), deg_to_dec(row['lon'])) print(row['name'], row['lat'], row['lon'], deg_to_dec(row['lat']), deg_to_dec(row['lon']))
+4
View File
@@ -0,0 +1,4 @@
pytest
mypy
flake8
pytest-asyncio
+2
View File
@@ -8,5 +8,7 @@ services:
restart: "always" restart: "always"
env_file: env_file:
- .env - .env
environment:
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
volumes: volumes:
- ./uploads:/app/uploads - ./uploads:/app/uploads
+14 -4
View File
@@ -5,10 +5,10 @@ services:
- 3306:3306 - 3306:3306
environment: environment:
- MYSQL_USER=root - MYSQL_USER=root
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=ull_tracker - MYSQL_DATABASE=ull_tracker
- MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
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
api: api:
build: build:
@@ -17,19 +17,29 @@ services:
- 8000:8000 - 8000:8000
volumes: volumes:
- .:/app - .:/app
env_file:
- .env
environment: environment:
TZ: "Europe/Prague" TZ: "Europe/Prague"
API_URL: "http://localhost:8000" API_URL: "http://localhost:8000"
APP_ENV: "development" APP_ENV: "development"
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001" MYSQL_USER: "root"
MYSQL_PASSWORD: "root"
ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001 http://localhost:9101 http://localhost:3000"
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
adminer:
image: adminer
restart: always
ports:
- 8080:8080
scrapy: scrapy:
build: build:
context: ../crawlers context: ../crawlers
environment: environment:
- MYSQL_USER=root - MYSQL_USER=root
- MYSQL_PASSWORD= - MYSQL_PASSWORD=root
- MYSQL_DATABASE=ull_tracker - MYSQL_DATABASE=ull_tracker
- MYSQL_HOST=db - MYSQL_HOST=db
volumes: volumes:
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+14 -13
View File
@@ -1,15 +1,16 @@
fastapi==0.100.0 fastapi==0.110.0
fastapi-jwt==0.1.12 fastapi-jwt==0.2.0
strawberry-graphql[fastapi]==0.194.4 fastapi-mail==1.4.1
uvicorn==0.22.0 strawberry-graphql[fastapi]==0.217.1
sqlalchemy[asyncio] >= 2.0.9 uvicorn==0.26.0
sqlalchemy[asyncio] == 2.0.25
aiomysql==0.2.0 aiomysql==0.2.0
alembic==1.11.1 alembic==1.13.1
passlib==1.7.4 passlib==1.7.4
pydantic==1.10.11 # vysla uz 2.0, ale nejak mi to nefunguje pydantic==2.5.3
sentry-sdk[fastapi]
pillow pillow==10.2.0
exif exif==1.6.0
aiocache aiocache==0.12.2
aiohttp aiohttp==3.9.3
lxml lxml==5.1.0
+41
View File
@@ -0,0 +1,41 @@
from aiohttp import ClientResponseError
from database import models
from database.transaction import get_session
from external.elevation import elevation_api
from external.gpx_parser import GPXParser
from paths import FLIGHT_GPX_TRACK_PATH
async def add_terrain_elevation_to_flight(flight_id: int, gpx_filename: str):
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
coordinates = await gpx_parser.get_coordinates()
try:
elevation = await elevation_api.get_elevation_for_points(coordinates)
tree_with_elevation = gpx_parser.add_terrain_elevation(elevation)
output_name = f"terrain_{gpx_filename}"
gpx_parser.write(tree_with_elevation, f"{FLIGHT_GPX_TRACK_PATH}/{output_name}")
async with get_session() as db:
await models.Flight.update(
db, {"gpx_track_filename": output_name, "has_terrain_elevation": True},
id=flight_id
)
except ClientResponseError as e:
print(e)
async def add_terrain_elevation_to_photo(photo):
try:
elevation = await elevation_api.get_elevation_for_points([
{"lat": photo.gps_latitude, "lng": photo.gps_longitude}
])
if not elevation:
print("Cannot get elevation")
return
terrain_elevation = elevation[0]['elevation']
async with get_session() as db:
await models.Photo.update(db_session=db, obj=photo, data={"terrain_elevation": terrain_elevation})
except Exception as e:
print(f"Cannot get elevation: {e}")
+41
View File
@@ -0,0 +1,41 @@
import os.path
from time import time
from database import models
from database.transaction import get_session
from utils.image import PhotoEditor
async def resize_photo(path: str, filename: str, photo_id: int):
editor = PhotoEditor(path, filename)
editor.resize(new_width=2500)
name, _ = os.path.splitext(filename)
editor.write_to_file(quality=95, format_="webp", dest_filename=f"{name}.webp")
editor.write_to_file(quality=80) # toto je potreba pro prvni nacteni nahledu ihned po nahrani, pripadne pro
# vygenerovani nahledu (async, muze se delat pred/behem zmensovani fotky -> v tu dobu jeste neexistuje webp)
width, height = editor.img_size
# TODO: doresit uklid JPGu -> jsou zbytecne
async with get_session() as db:
await models.Photo.update(
db,
id=photo_id,
data={
"width": width,
"height": height,
"filename_extension": "webp",
"cache_key": int(time())
})
async def generate_thumbnail(path: str, filename: str):
editor = PhotoEditor(path, filename)
editor.resize(new_width=300)
name, _ = os.path.splitext(filename)
editor.write_to_file(
quality=85,
dest_path=f"{path}/thumbs",
dest_filename=f"{name}.webp",
format_="webp")
+7
View File
@@ -0,0 +1,7 @@
from emails.emails import Email
from mailer import Mailer
async def send_email(email: Email):
mailer = Mailer()
return await mailer.send(email)
+41
View File
@@ -0,0 +1,41 @@
from datetime import datetime
from typing import Literal
from database import models
from database.transaction import get_session
from external.weather import weather_api
from logger import log
async def download_weather(date_time: datetime, flight_id: int, airport_id: int, type_: Literal['landing', 'takeoff']):
async with get_session() as db:
airport = await models.Airport.get_one(db, id=airport_id)
gps = (airport.gps_latitude, airport.gps_longitude)
try:
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps)
log.warning(weather)
except Exception as e:
log.error(f"Error in downloading weather: {e}")
return None
data = {
"datetime": weather['datetime'],
"qnh": weather['pressure_msl'],
"temperature_surface": weather['temperature_2m'],
"dewpoint_surface": weather['dewpoint_2m'],
"rain": weather['rain'],
"cloudcover_total": weather['cloudcover'],
"cloudcover_low": weather['cloudcover_low'],
"wind_speed_surface": weather['windspeed_10m'],
"wind_direction_surface": weather['winddirection_10m'],
}
async with get_session() as db:
flight = await models.Flight.get_one(db, id=flight_id)
existing_weather_id = getattr(flight, f'{type_}_weather_info_id')
if existing_weather_id:
await models.WeatherInfo.update(db, id=existing_weather_id, data=data)
else:
weather_model = await models.WeatherInfo.create(db, data=data)
await models.Flight.update(db, obj=flight, data={f"{type_}_weather_info_id": weather_model.id})
+11 -1
View File
@@ -2,9 +2,19 @@ import os
APP_DEBUG = True APP_DEBUG = True
GRAPHIQL = True GRAPHIQL = True
REFRESH_TOKEN_VALIDITY_DAYS = 30
API_URL = os.environ.get("API_URL") or "http://localhost:8000" API_URL = os.environ.get("API_URL") or "http://localhost:8000"
APP_SECRET_KEY = os.environ.get("APP_SECRET_KEY") or "test"
ALLOW_CORS_ORIGINS = os.environ.get("ALLOW_CORS_ORIGINS", "").split() ALLOW_CORS_ORIGINS = os.environ.get("ALLOW_CORS_ORIGINS", "").split()
SENTRY_DSN = os.environ.get("SENTRY_DSN")
APP_SECRET_KEY = os.environ.get("APP_SECRET_KEY") or "test"
SMTP_HOST = os.environ.get("SMTP_HOST")
SMTP_PORT = int(os.environ.get("SMTP_PORT") or 0)
SMTP_USERNAME = os.environ.get("SMTP_USERNAME")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD")
if not APP_SECRET_KEY: if not APP_SECRET_KEY:
raise ValueError("Missing APP_SECRET_KEY!") raise ValueError("Missing APP_SECRET_KEY!")
+151 -49
View File
@@ -1,29 +1,30 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from typing import Set, List from typing import Set, List
from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float, Enum
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@as_declarative() @as_declarative()
class BaseModel: class BaseModel:
excluded_columns_in_dict = tuple() excluded_columns_in_dict = ("deleted",)
def as_dict(self):
return {
c.name: getattr(self, c.name)
for c in self.__table__.columns
if c.name not in self.excluded_columns_in_dict
}
@classmethod @classmethod
async def get_one(cls, db_session: AsyncSession, id: int): def _get_column_names(cls):
return (await db_session.scalars(select(cls).filter_by(id=id))).one() return [col.name for col in cls.__table__.columns]
def as_dict(self):
return {c: getattr(self, c) for c in self._get_column_names() if c not in self.excluded_columns_in_dict}
@classmethod
async def get_one(cls, db_session: AsyncSession, **kwargs):
return (await db_session.scalars(select(cls).filter_by(**kwargs))).one()
@classmethod @classmethod
async def create(cls, db_session: AsyncSession, data: dict): async def create(cls, db_session: AsyncSession, data: dict):
model = cls(**data) model = cls(**{col: data[col] for col in cls._get_column_names() if col in data})
db_session.add(model) db_session.add(model)
await db_session.flush() await db_session.flush()
@@ -35,9 +36,9 @@ class BaseModel:
raise ValueError("Provide either obj or id!") raise ValueError("Provide either obj or id!")
if not obj: if not obj:
obj = await cls.get_one(db_session, id) obj = await cls.get_one(db_session, id=id)
for key, value in data.items(): for key, value in data.items():
if getattr(obj, key) != value: if key in cls._get_column_names() and getattr(obj, key) != value:
setattr(obj, key, value) setattr(obj, key, value)
return obj return obj
@@ -55,8 +56,30 @@ flight_has_copilot = Table(
BaseModel.metadata, BaseModel.metadata,
Column("flight_id", ForeignKey("flight.id"), primary_key=True), Column("flight_id", ForeignKey("flight.id"), primary_key=True),
Column("copilot_id", ForeignKey("copilot.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",
BaseModel.metadata,
Column("copilot_id", ForeignKey("copilot.id"), primary_key=True),
Column("photo_id", ForeignKey("photo.id"), primary_key=True),
)
class AirspaceItem(BaseModel):
__tablename__ = "airspace_item"
id: Mapped[int] = mapped_column(primary_key=True)
country: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
type: Mapped[str] = mapped_column(String(8), nullable=False, index=True)
lower_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())
radius: Mapped[float] = mapped_column(Float, nullable=True)
bounds: Mapped[list[tuple[float, float]]] = mapped_column(JSON())
class Airport(BaseModel): class Airport(BaseModel):
__tablename__ = "airport" __tablename__ = "airport"
@@ -64,15 +87,17 @@ class Airport(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
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=False) gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False) 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
use_in_gpx_guess: Mapped[bool] = mapped_column(Boolean, server_default='1')
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') 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 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') deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
metars: Mapped['Metar'] = relationship(back_populates="airport") metars: Mapped['Metar'] = relationship()
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
@@ -96,7 +121,9 @@ class PointOfInterest(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="")
title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True)
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)
type_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest_type.id"), nullable=True) type_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest_type.id"), nullable=True)
@@ -105,31 +132,60 @@ class PointOfInterest(BaseModel):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
photos: Mapped[List[Photo]] = relationship()
type: Mapped[PointOfInterestType] = relationship() type: Mapped[PointOfInterestType] = relationship()
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
class Photo(BaseModel): class Photo(BaseModel):
__tablename__ = "photo" __tablename__ = "photo"
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, server_default="")
description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
filename: Mapped[str] = mapped_column(String(128), nullable=False) filename: Mapped[str] = mapped_column(String(128), nullable=False)
is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0") filename_extension: Mapped[str] = mapped_column(String(4), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) cache_key: Mapped[str] = mapped_column(String(128), nullable=True)
width: Mapped[int] = mapped_column(Integer, nullable=False)
height: Mapped[int] = mapped_column(Integer, nullable=False)
exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True)
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)
gps_altitude: Mapped[float] = mapped_column(Float, nullable=True) gps_altitude: Mapped[float] = mapped_column(Float, nullable=True)
terrain_elevation: Mapped[float] = mapped_column(Float, nullable=True)
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=True)
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)
flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False) flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False)
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_by_copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id]) flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id])
point_of_interest: Mapped['PointOfInterest'] = relationship() point_of_interest: Mapped['PointOfInterest'] = relationship(foreign_keys=[point_of_interest_id])
adjustment: Mapped['PhotoAdjustment'] = relationship(passive_deletes=True)
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
aircraft: Mapped['Aircraft'] = relationship(foreign_keys=[aircraft_id])
copilots: Mapped[List['Copilot']] = relationship(secondary=copilot_has_photo)
class PhotoAdjustment(BaseModel):
__tablename__ = "photo_adjustment"
id: Mapped[int] = mapped_column(primary_key=True)
photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id', ondelete='CASCADE'), nullable=False)
rotate: Mapped[float] = mapped_column(Float, nullable=True)
contrast: Mapped[float] = mapped_column(Float, nullable=True)
brightness: Mapped[float] = mapped_column(Float, nullable=True)
saturation: Mapped[float] = mapped_column(Float, nullable=True)
sharpness: Mapped[float] = mapped_column(Float, nullable=True)
crop_left: Mapped[float] = mapped_column(Float, nullable=True)
crop_top: Mapped[float] = mapped_column(Float, nullable=True)
crop_width: Mapped[float] = mapped_column(Float, nullable=True)
crop_height: Mapped[float] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
photo: Mapped['Photo'] = relationship()
class Aircraft(BaseModel): class Aircraft(BaseModel):
@@ -137,7 +193,8 @@ class Aircraft(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
call_sign: Mapped[str] = mapped_column(String(16), nullable=False) call_sign: Mapped[str] = mapped_column(String(16), nullable=False)
photo_filename: Mapped[str] = mapped_column(String(128), nullable=True) title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
manufacturer: Mapped[str] = mapped_column(Text, nullable=False, server_default="") manufacturer: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
model: Mapped[str] = mapped_column(String(30), nullable=False, server_default="") model: Mapped[str] = mapped_column(String(30), nullable=False, server_default="")
seats: Mapped[str] = mapped_column(Integer, nullable=False) seats: Mapped[str] = mapped_column(Integer, nullable=False)
@@ -150,22 +207,22 @@ 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() # notes: Mapped['AircraftNotes'] = relationship()
class AircraftNotes(BaseModel): # class AircraftNotes(BaseModel):
__tablename__ = "aircraft_notes" # __tablename__ = "aircraft_notes"
#
id: Mapped[int] = mapped_column(primary_key=True) # id: Mapped[int] = mapped_column(primary_key=True)
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False) # aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False) # name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) # description: Mapped[str] = mapped_column(Text, nullable=False)
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') # is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) # created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) # created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
#
created_by: Mapped['User'] = relationship() # created_by: Mapped['User'] = relationship()
aircraft: Mapped['Aircraft'] = relationship(back_populates="notes") # aircraft: Mapped['Aircraft'] = relationship()
class Organization(BaseModel): class Organization(BaseModel):
@@ -177,7 +234,7 @@ class Organization(BaseModel):
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
users: Mapped[Set['User']] = relationship(back_populates='organizations', secondary=user_is_in_organization) users: Mapped[Set['User']] = relationship(secondary=user_is_in_organization)
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
@@ -186,11 +243,14 @@ class FlightTrack(BaseModel):
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=False) point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True)
airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
landing_duration: Mapped[int] = mapped_column(Integer, nullable=True)
order: Mapped[int] = mapped_column(Integer) order: Mapped[int] = mapped_column(Integer)
flight: Mapped['Flight'] = relationship() flight: Mapped['Flight'] = relationship()
point_of_interest: Mapped['PointOfInterest'] = relationship() point_of_interest: Mapped['PointOfInterest'] = relationship()
airport: Mapped['Airport'] = relationship()
class WeatherInfo(BaseModel): class WeatherInfo(BaseModel):
@@ -208,19 +268,43 @@ class WeatherInfo(BaseModel):
datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
class Event(BaseModel):
__tablename__ = "event"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="")
date_from: Mapped[datetime] = mapped_column(DateTime, nullable=True)
date_to: Mapped[datetime] = mapped_column(DateTime, nullable=True)
organization_id: Mapped[int] = mapped_column(Integer, ForeignKey('organization.id'), nullable=True)
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())
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
organization: Mapped['Organization'] = relationship()
created_by: Mapped['User'] = relationship()
class Flight(BaseModel): class Flight(BaseModel):
__tablename__ = "flight" __tablename__ = "flight"
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, server_default="")
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) debriefing_notes: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="")
event_id: Mapped[int] = mapped_column(Integer, ForeignKey("event.id"), nullable=True)
title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True)
takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True)
takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
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) gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True)
has_terrain_elevation: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
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)
@@ -235,10 +319,12 @@ class Flight(BaseModel):
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() track: Mapped['FlightTrack'] = 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(back_populates="flights") 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])
class Copilot(BaseModel): class Copilot(BaseModel):
@@ -246,12 +332,17 @@ class Copilot(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="")
title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True)
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
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)
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
photos: Mapped[List['Photo']] = relationship(secondary=copilot_has_photo)
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
class Metar(BaseModel): class Metar(BaseModel):
@@ -263,7 +354,7 @@ class Metar(BaseModel):
issued_at: Mapped[datetime] = mapped_column(DateTime) issued_at: Mapped[datetime] = mapped_column(DateTime)
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
airport: Mapped['Airport'] = relationship(back_populates="metars") airport: Mapped['Airport'] = relationship()
class License(BaseModel): class License(BaseModel):
@@ -276,7 +367,7 @@ class License(BaseModel):
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
user: Mapped['User'] = relationship(back_populates="licences") user: Mapped['User'] = relationship()
created_by: Mapped['User'] = relationship() created_by: Mapped['User'] = relationship()
@@ -287,7 +378,7 @@ class User(BaseModel):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) email: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(128), nullable=False) name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
public_username: Mapped[str] = mapped_column(String(128), nullable=True, unique=True) public_username: Mapped[str] = mapped_column(String(128), nullable=True, unique=True)
avatar_image_filename: Mapped[str] = mapped_column(String(128), nullable=True) avatar_image_filename: Mapped[str] = mapped_column(String(128), nullable=True)
title_image_filename: Mapped[str] = mapped_column(String(128), nullable=True) title_image_filename: Mapped[str] = mapped_column(String(128), nullable=True)
@@ -297,3 +388,14 @@ class User(BaseModel):
licences: Mapped[Set['License']] = relationship() 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)
class PasswordResetToken(BaseModel):
__tablename__ = "password_reset_token"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
token: Mapped[str] = mapped_column(String(64), nullable=False)
valid_until: Mapped[datetime] = mapped_column(DateTime, nullable=True)
user: Mapped['User'] = relationship()
+67
View File
@@ -0,0 +1,67 @@
from typing import Optional, Type
from sqlalchemy import select, or_, and_
from database import models
class QueryBuilder:
def __init__(self, model: Type[models.BaseModel]):
self.model = model
def get_simple_query(
self,
extra_select: Optional[list] = None,
created_by_id: Optional[int] = None,
order_by: Optional[list] = None,
only_public: Optional[bool] = False,
only_my: Optional[bool] = False,
include_others_public: Optional[bool] = False,
url_slug: Optional[str] = None,
include_deleted: bool = False
):
if not extra_select:
extra_select = []
query = select(self.model, *extra_select)
if not include_deleted and hasattr(self.model, "deleted"):
query = query.filter(self.model.deleted.is_(False))
ownership_filters = []
my_filters = []
if only_public and hasattr(self.model, "is_public"):
my_filters.append(self.model.is_public.is_(True))
if hasattr(self.model, "url_slug"):
my_filters.append(self.model.url_slug != '')
if only_my:
if created_by_id and hasattr(self.model, "created_by_id"):
my_filters.append(self.model.created_by_id == created_by_id)
others_filters = []
if include_others_public:
if hasattr(self.model, "is_public"):
others_filters.append(self.model.is_public.is_(True))
if hasattr(self.model, "created_by_id"):
others_filters.append(or_(
self.model.created_by_id != created_by_id,
self.model.created_by_id.is_(None)
))
if my_filters:
ownership_filters.append(and_(*my_filters))
if others_filters:
ownership_filters.append(and_(*others_filters))
query = query.filter(or_(*ownership_filters))
if url_slug is not None and hasattr(self.model, 'url_slug'):
query = query.filter(self.model.url_slug == url_slug)
if order_by:
query = query.order_by(*order_by)
elif hasattr(self.model, "name"):
query = query.order_by(self.model.name)
return query
+18
View File
@@ -0,0 +1,18 @@
from contextlib import asynccontextmanager
from database import async_session
@asynccontextmanager
async def get_session():
async with async_session() as session:
async with session.begin():
try:
yield session
await session.commit()
except Exception as e:
await session.rollback()
print(f"ERROR: {e}")
raise
finally:
session.expunge_all()
await session.close()
+17 -3
View File
@@ -3,8 +3,22 @@ from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED from starlette.status import HTTP_401_UNAUTHORIZED
def public_endpoint(func): def raise_unauthorized():
pass raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized")
def allow_public(func):
@wraps(func)
async def decorator(*args, **kwargs):
if 'info' in kwargs:
user_id = kwargs['info'].context.user_id
public = kwargs.get('public')
if not user_id and not public:
raise_unauthorized()
return await func(*args, **kwargs)
return decorator
def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_unauthorized=None): def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_unauthorized=None):
@@ -14,7 +28,7 @@ def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_u
if 'info' in kwargs: if 'info' in kwargs:
if not kwargs['info'].context.user_id: if not kwargs['info'].context.user_id:
if raise_when_unauthorized: if raise_when_unauthorized:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized") raise_unauthorized()
else: else:
return return_value_unauthorized return return_value_unauthorized
return await func(*args, **kwargs) return await func(*args, **kwargs)
+5 -1
View File
@@ -1,4 +1,5 @@
from functools import wraps from functools import wraps
from fastapi import HTTPException
from graphql import GraphQLError from graphql import GraphQLError
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
@@ -9,6 +10,9 @@ def error_logging(func):
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
except NoResultFound as e: except NoResultFound as e:
raise GraphQLError(f"Not found", original_error=e) raise GraphQLError("Not found", original_error=e)
except HTTPException as e:
if e.status_code == 401:
raise GraphQLError("Not authorized", original_error=e)
return decorator return decorator
-9
View File
@@ -1,9 +0,0 @@
from database import async_session
async def db_session():
async with async_session() as session:
async with session.begin():
yield session
await session.flush()
await session.commit()
-10
View File
@@ -1,10 +0,0 @@
# from fastapi import Depends, Security
# from fastapi_jwt import JwtAuthorizationCredentials
#
#
# async def check_jwt_token():
# authorize.jwt_required()
#
# subject = authorize.get_jwt_subject()
#
# print("Subject: ", subject)
+54
View File
@@ -0,0 +1,54 @@
from typing import Any
from fastapi_mail import MessageSchema, MessageType
from pydantic import EmailStr
class Email(MessageSchema):
template: str
def __init__(
self, to: list[EmailStr | str], subject: str, template: str, template_variables: dict, **data: Any
) -> None:
data['subtype'] = MessageType.html
data['recipients'] = to
data['template'] = template
data['template_body'] = template_variables
data['subject'] = subject + " | Polétání.cz"
super().__init__(**data)
class ContactFormEmail(Email):
def __init__(self, email_from: str, content: str):
super().__init__(
to=["michal@kvacek.cz"],
reply_to=[email_from],
subject="Zpráva z kontaktního formuláře",
template="contact_form.html",
template_variables={
"content": content,
"email_from": email_from
}
)
class WelcomeEmail(Email):
def __init__(self, to: EmailStr | str):
super().__init__(
to=[to],
subject="Vítej",
template="welcome.html",
template_variables={}
)
class ResetPasswordEmail(Email):
def __init__(self, to: EmailStr | str, reset_token: str, validity_hours: int):
super().__init__(
to=[to],
subject="Žádost o reset",
template="reset_password.html",
template_variables={
"token": reset_token,
"validity_hours": validity_hours,
}
)
@@ -0,0 +1,22 @@
<mjml>
<mj-body background-color="#f4f4f4">
<mj-container>
<mj-section>
<mj-column>
<mj-divider border-color="#469ccc"></mj-divider>
<mj-text font-size="20px" color="#469ccc" font-family="Arial, sans-serif" align="center" padding-bottom="20px">
Zpráva z kontaktního formuláře | Polétání.cz
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="left">
Od: [from]
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="left" line-height="1.5">
[content]
</mj-text>
<mj-divider border-color="#469ccc"></mj-divider>
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>
@@ -0,0 +1,29 @@
<mjml>
<mj-body background-color="#f4f4f4">
<mj-container>
<mj-section>
<mj-column>
<mj-divider border-color="#469ccc"></mj-divider>
<mj-text font-size="20px" color="#469ccc" font-family="Arial, sans-serif" align="center" padding-bottom="20px">
Zapomenuté heslo | Polétání.cz
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="left" line-height="1.5">
<p>
Ahoj,
<br>
Každý občas zapomíná. Heslo je to nejmenší. Klikni na následující tlačítko pro přihlášení do aplikace. A tam si budeš moct změnit heslo.
<br>
<strong>Jen admin vyzaduje zadani stareho hesla, takze to nebude tak snadne...</strong>
</p>
</mj-text>
<mj-button background-color="#469ccc" color="white" font-size="18px" align="center" href="">
Přihlásit se pomocí magického odkazu [login_token]
</mj-button>
<mj-divider border-color="#469ccc"></mj-divider>
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>
+46
View File
@@ -0,0 +1,46 @@
<mjml>
<mj-body background-color="#f4f4f4">
<mj-container>
<mj-section>
<mj-column>
<mj-divider border-color="#469ccc"></mj-divider>
<mj-text font-size="20px" color="#469ccc" font-family="Arial, sans-serif" align="center" padding-bottom="20px">
Vítejte v aplikaci Polétání.cz!
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="left" line-height="1.5">
Ahoj [Jméno],
<br>
<p>
Vítejte v aplikaci Polétání.cz – místě, kde můžete sdílet své letecké dobrodružství a fotografie s ostatními nadšenci.
</p>
<p>
Co můžete očekávat:
</p>
<ul>
<li>Jednoduché nahrávání a sdílení fotografií z vašich letů.</li>
<li>Možnost připojení k letištním komunitám a sdílení tipů a triků.</li>
<li>Upozornění na nové funkce a akce v aplikaci.</li>
</ul>
<p>
Začněte nyní a sdílejte své vzdušné zážitky s celým světem!
</p>
<p>
S pozdravem,
<br>
Tým Polétání.cz
</p>
</mj-text>
<mj-button background-color="#469ccc" color="white" font-size="16px" align="center" href="[Odkaz na přihlášení]">
Přihlásit se do Polétání.cz
</mj-button>
<mj-divider border-color="#469ccc"></mj-divider>
</mj-column>
</mj-section>
</mj-container>
</mj-body>
</mjml>
-6
View File
@@ -1,12 +1,6 @@
from fastapi_jwt.jwt import JwtAccess, JwtRefresh from fastapi_jwt.jwt import JwtAccess, JwtRefresh
class BaseEndpoint(object):
def __init__(self, db):
self.db = db
super().__init__()
class AuthEndpoint: class AuthEndpoint:
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.access_security: JwtAccess = kwargs.pop("access_token") self.access_security: JwtAccess = kwargs.pop("access_token")
+25
View File
@@ -0,0 +1,25 @@
from fastapi import BackgroundTasks
from pydantic import BaseModel
from background_jobs.send_email import send_email
from emails.emails import ContactFormEmail
class ContactInput(BaseModel):
email_from: str
content: str
class ContactEndpoint():
async def on_post(self, input: ContactInput, backgorund_tasks: BackgroundTasks):
backgorund_tasks.add_task(send_email, ContactFormEmail(
email_from=input.email_from,
content=input.content
))
return {
"status": "success",
"email": {
"from": input.email_from,
"content": input.content
}
}
+81
View File
@@ -0,0 +1,81 @@
import random
import string
from datetime import datetime, timedelta
from fastapi import BackgroundTasks
from passlib.hash import bcrypt
from pydantic import BaseModel
from background_jobs.send_email import send_email
from database import models
from database.transaction import get_session
from emails.emails import ResetPasswordEmail
class ForgottenPasswordRequest(BaseModel):
email: str
class ChangeForgottenPassword(BaseModel):
password: str
token: str
class ForgottenPasswordEndpoint():
@staticmethod
def generate_random_token(length):
characters = string.ascii_letters + string.digits
return ''.join(random.choice(characters) for i in range(length))
@staticmethod
async def token_info(token: str):
async with get_session() as db:
reset = await models.PasswordResetToken.get_one(db, token=token)
user = await models.User.get_one(db, id=reset.user_id)
token_validity = reset.valid_until
email, name = user.email, user.name
return {
"token": token,
"token_validity": token_validity,
"user_email": email,
"user_name": name
}
async def request(self, input: ForgottenPasswordRequest, backgorund_tasks: BackgroundTasks):
validity_hours = 8
validity = datetime.now() + timedelta(hours=validity_hours)
random_str_token = self.generate_random_token(64)
async with get_session() as db:
user = await models.User.get_one(db, email=input.email)
token = await models.PasswordResetToken.create(db, {
"user_id": user.id,
"token": random_str_token,
"valid_until": validity
})
backgorund_tasks.add_task(
send_email,
email=ResetPasswordEmail(
to=input.email,
reset_token=token,
validity_hours=validity_hours
)
)
@staticmethod
async def change_password(input: ChangeForgottenPassword):
async with get_session() as db:
token = await models.PasswordResetToken.get_one(db, token=input.token)
user_id = token.user_id
await models.User.update(
db, id=token.user_id,
data={"password_hashed": bcrypt.hash(input.password)}
)
await db.delete(token)
return {
"user_id": user_id
}
+53
View File
@@ -0,0 +1,53 @@
from datetime import timedelta
from fastapi import FastAPI, Security, Depends, BackgroundTasks, APIRouter
from fastapi_jwt import JwtAuthorizationCredentials
from fastapi_jwt.jwt import JwtAccessBearerCookie
from sqlalchemy import select
from starlette.responses import RedirectResponse
from strawberry.fastapi import GraphQLRouter
from config import GRAPHIQL, APP_DEBUG
from database import async_session, models
from graphql_schema.schema import GraphQLContext, schema
def setup_graphql_endpoint(app: FastAPI, access_security: JwtAccessBearerCookie):
if not APP_DEBUG:
return
debug_router = APIRouter()
@debug_router.get("/graphql/autologin")
async def autologin():
access_token = access_security.create_access_token(subject={"id": 6, "name": "Franta Vomacka"})
response = RedirectResponse(url="/graphql")
access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14))
return response
app.include_router(debug_router)
async def setup_graphql_context(credentials: JwtAuthorizationCredentials = Security(access_security)):
user_id = credentials['id'] if credentials else None
organization_ids = set()
if user_id:
async with async_session() as db:
organization_ids = set((await db.scalars(
select(models.user_is_in_organization.c.organization_id)
.filter(models.user_is_in_organization.c.user_id == user_id)
)).all())
return GraphQLContext(
user_id=user_id,
organization_ids=organization_ids,
jwt_auth_credentials=credentials,
jwt=access_security,
background_tasks=Depends(BackgroundTasks)
)
graphql_app = GraphQLRouter(
schema,
graphiql=GRAPHIQL,
debug=APP_DEBUG,
context_getter=setup_graphql_context
)
app.include_router(graphql_app, prefix="/graphql")
+12 -8
View File
@@ -4,7 +4,8 @@ from passlib.hash import bcrypt
from sqlalchemy import select from sqlalchemy import select
from starlette.responses import Response from starlette.responses import Response
from database.models import User from database.models import User
from endpoints.base import BaseEndpoint, AuthEndpoint from database.transaction import get_session
from endpoints.base import AuthEndpoint
from pydantic import BaseModel from pydantic import BaseModel
@@ -13,18 +14,22 @@ class LoginInput(BaseModel):
password: str password: str
class LoginEndpoint(AuthEndpoint, BaseEndpoint): class LoginEndpoint(AuthEndpoint):
async def on_post(self, user_data: LoginInput, resp: Response) -> dict: async def on_post(self, user_data: LoginInput, resp: Response) -> dict:
query = select(User).filter_by(email=user_data.email) query = select(User).filter_by(email=user_data.email)
logged_user = (await self.db.scalars(query)).first()
async with get_session() as db:
logged_user = (await db.scalars(query)).first()
if not logged_user: if not logged_user:
raise HTTPException(status_code=401, detail="Invalid user") raise HTTPException(status_code=401, detail="Invalid user")
if not bcrypt.verify(user_data.password, logged_user.password_hashed): user = logged_user.as_dict()
password_hashed = logged_user.password_hashed
if not bcrypt.verify(user_data.password, password_hashed):
raise HTTPException(status_code=401, detail="Bad username or password") raise HTTPException(status_code=401, detail="Bad username or password")
subject = {"id": logged_user.id, "email": logged_user.email} subject = {"id": user['id'], "email": user['email']}
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)
@@ -35,9 +40,8 @@ class LoginEndpoint(AuthEndpoint, BaseEndpoint):
) )
return { return {
"user": logged_user.as_dict(), "user": user,
"access_token": access_token, "access_token": access_token
"access_token_validity": self.access_security.access_expires_delta.total_seconds(),
} }
+51
View File
@@ -0,0 +1,51 @@
import os.path
from sqlalchemy import select
from starlette.responses import StreamingResponse
from database import models
from database.transaction import get_session
from endpoints.base import AuthEndpoint
from paths import get_photo_basepath
from utils.image import PhotoEditor
class PhotoEditorEndpoint(AuthEndpoint):
@staticmethod
async def show_preview(photo_id: int, **kwargs):
async with get_session() as db:
photo = (await db.scalars(
select(models.Photo)
.filter(models.Photo.id == photo_id)
)).one()
basepath = get_photo_basepath(photo.flight_id)
filename = f"{photo.filename}.{photo.filename_extension}"
original_filename = '_original_' + filename
if os.path.exists(f"{basepath}/{original_filename}"):
filename = original_filename
editor = PhotoEditor(basepath, filename)
editor.resize(new_height=900)
# TODO: idealni je udelat co nejdriv resize
# velikost muze ovlivnit: orez, otoceni, coz jsou dve nejnarocnejsi operace...
# ^^ tohle by bylo fajn cachovat
if kwargs.get("rotate"):
editor.rotate(kwargs['rotate'], True)
crop = {
key.replace("crop_", ""): value
for key, value in kwargs.items()
if key in ('crop_left', 'crop_top', 'crop_width', 'crop_height') and value is not None
}
if crop and len(crop.keys()) == 4:
editor.crop(**crop)
adjustments = {
key: value for key, value in kwargs.items()
if key in ('saturation', 'brightness', 'contrast', 'sharpness') and value is not None
}
if adjustments:
editor.adjust(**adjustments)
return StreamingResponse(content=editor.get_as_stream(), media_type="image/jpeg")
+13 -10
View File
@@ -1,11 +1,13 @@
import re import re
from fastapi import HTTPException from fastapi import HTTPException, BackgroundTasks
from sqlalchemy import select from sqlalchemy import select
from typing import Optional from typing import Optional
from pydantic import BaseModel, root_validator, Field from pydantic import BaseModel, Field, model_validator
from background_jobs.send_email import send_email
from database.models import User from database.models import User
from endpoints.base import BaseEndpoint from database.transaction import get_session
from passlib.hash import bcrypt from passlib.hash import bcrypt
from emails.emails import WelcomeEmail
class RegistrationInput(BaseModel): class RegistrationInput(BaseModel):
@@ -13,7 +15,7 @@ class RegistrationInput(BaseModel):
name: Optional[str] name: Optional[str]
password: str password: str
@root_validator() @model_validator(mode="before")
def validate_email(cls, values): def validate_email(cls, values):
email = values.get("email") or "" email = values.get("email") or ""
@@ -23,22 +25,23 @@ class RegistrationInput(BaseModel):
return values return values
class RegistrationEndpoint(BaseEndpoint): class RegistrationEndpoint:
@staticmethod
async def on_post(self, user_data: RegistrationInput) -> User: async def on_post(user_data: RegistrationInput, backgorund_tasks: BackgroundTasks) -> User:
query = select(User).filter_by(email=user_data.email) query = select(User).filter_by(email=user_data.email)
existing_user = (await self.db.scalars(query)).first() async with get_session() as db:
existing_user = (await db.scalars(query)).first()
if existing_user: if existing_user:
raise HTTPException(status_code=422, detail="User already exists") raise HTTPException(status_code=422, detail="User already exists")
model = await User.create(self.db, { model = await User.create(db, {
"name": user_data.name, "name": user_data.name,
"email": user_data.email, "email": user_data.email,
"password_hashed": bcrypt.hash(user_data.password), "password_hashed": bcrypt.hash(user_data.password),
"description": "" "description": ""
}) })
await self.db.commit() backgorund_tasks.add_task(send_email, WelcomeEmail(to=user_data.email))
return model.as_dict() return model.as_dict()
+133
View File
@@ -0,0 +1,133 @@
from pydantic import BaseModel
from sqlalchemy import select
from starlette.responses import Response
from database import models
from database.transaction import get_session
class SitemapURL(BaseModel):
url: str
priority: float
class SitemapEndpoint():
def get_static_routes(self):
routes = [
("", 1),
("kontakt", 0.5),
("napoveda", 0.7),
("podminky", 0.5),
("mista", 0.8),
("o-projektu", 0.8),
("osobni-udaje", 0.4),
("funkce", 0.8)
]
return [SitemapURL(url=url, priority=priority) for url, priority in routes]
async def get_public_pilots(self):
async with get_session() as db:
pilots = (await db.scalars(
select(models.User.public_username)
.filter(models.User.public_username != '')
))
return [SitemapURL(url=username, priority=0.8) for username in pilots]
async def get_public_flights(self):
async with get_session() as db:
flights = (await db.execute(
select(models.Flight, models.User.public_username)
.join(models.Flight.created_by)
.filter(models.Flight.is_public.is_(True))
.filter(models.Flight.url_slug != '')
.filter(models.User.public_username != '')
)).fetchall()
return [SitemapURL(
url=f"{pilot_username}/{flight.url_slug}",
priority=0.8
) for flight, pilot_username in flights]
async def get_public_copilots(self):
async with get_session() as db:
copilots = (await db.execute(
select(models.Copilot, models.User.public_username)
.join(models.Copilot.created_by)
.filter(models.Copilot.is_public.is_(True))
.filter(models.Copilot.url_slug != '')
.filter(models.User.public_username != '')
)).fetchall()
return [SitemapURL(
url=f"{pilot_username}/a/{copilot.url_slug}",
priority=0.5
) for copilot, pilot_username in copilots]
async def get_public_aircrafts(self):
async with get_session() as db:
aircrafts = (await db.scalars(
select(models.Aircraft.call_sign)
.filter(models.Aircraft.is_public.is_(True))
)).fetchall()
return [SitemapURL(url=f"letadlo/{call_sign}", priority=0.6) for call_sign in aircrafts]
async def get_public_places(self):
async with get_session() as db:
pois = (await db.scalars(
select(models.PointOfInterest)
.filter(models.PointOfInterest.is_public.is_(True))
.filter(models.PointOfInterest.url_slug != '')
)).fetchall()
return [SitemapURL(url=f"mista/{poi.url_slug}", priority=0.6) for poi in pois]
async def get_public_events(self):
async with get_session() as db:
data = (await db.execute(
select(models.Event, models.User.public_username)
.join(models.Event.created_by)
.filter(models.Event.is_public.is_(True))
.filter(models.User.public_username != '')
))
pilots_with_events = set()
routes = []
for event, pilot_username in data:
if pilot_username not in pilots_with_events:
routes.append(SitemapURL(url=f"{pilot_username}/udalosti", priority=0.7))
pilots_with_events.add(pilot_username)
routes.append(SitemapURL(url=f"{pilot_username}/udalosti/{event.url_slug}", priority=0.7))
return routes
async def on_get(self):
my_sitemap = """<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">"""
sitemap_entries = [
*self.get_static_routes(),
*(await self.get_public_pilots()),
*(await self.get_public_flights()),
*(await self.get_public_aircrafts()),
*(await self.get_public_events()),
*(await self.get_public_places()),
*(await self.get_public_copilots()),
]
# copilots = self.get_public_copilots()
for sitemap_entry in sitemap_entries:
my_sitemap += f"""
<url>
<loc>https://poletani.cz/{sitemap_entry.url}</loc>
<priority>{sitemap_entry.priority}</priority>
</url>
"""
my_sitemap += "</urlset>"
return Response(content=my_sitemap, media_type="application/xml")
+4 -2
View File
@@ -1,5 +1,4 @@
from typing import List, Tuple, Dict from typing import List, Dict
import aiohttp import aiohttp
@@ -23,3 +22,6 @@ class ElevationAPI:
"lng": loc['longitude'], "lng": loc['longitude'],
"elevation": loc['elevation'] "elevation": loc['elevation']
} for loc in response['results']] } for loc in response['results']]
elevation_api = ElevationAPI()
+70 -14
View File
@@ -1,6 +1,6 @@
from collections import defaultdict
from datetime import datetime from datetime import datetime
from typing import List, Dict from typing import List, Dict, Any
from aiocache import cached from aiocache import cached
from lxml import etree from lxml import etree
from lxml.etree import _ElementTree from lxml.etree import _ElementTree
@@ -12,8 +12,55 @@ class GPXParser:
self.gpx = etree.parse(self.file) self.gpx = etree.parse(self.file)
self.namespace = None self.namespace = None
self.precision_digits = 6 self.precision_digits = 6
self.max_samples_per_minute = 2
self.set_namespace() self.set_namespace()
def get_timestamp_key(self, dt: datetime):
ts = round(dt.replace(microsecond=0).timestamp())
return ts - (ts % (60 / self.max_samples_per_minute))
async def group_data(self, data: list):
times = await self.get_times_all()
indexed_data: dict[datetime, Any] = {}
for time, item in zip(times, data):
indexed_data[time] = item
sampled_data = defaultdict(list)
for time, item in indexed_data.items():
key = self.get_timestamp_key(time)
sampled_data[key].append(item)
return sampled_data
async def average_sample_numbers(self, data: list[int | float]):
sampled_data = await self.group_data(data)
return [sum(items) / len(items) for items in sampled_data.values()]
async def sample_times(self, times: List[datetime]):
sampled_times = set()
for time in times:
original_tz = time.tzinfo
sampled_times.add(datetime.fromtimestamp(self.get_timestamp_key(time), tz=original_tz))
times_sorted = list(sampled_times)
times_sorted.sort()
return times_sorted
async def average_coordinates(self, coordinates: list[dict[str, float]]):
sampled_data = await self.group_data(coordinates)
coordinates_aggregated = []
for coordinates_group in sampled_data.values():
lat = [c['lat'] for c in coordinates_group]
lng = [c['lng'] for c in coordinates_group]
coordinates_aggregated.append({
"lat": sum(lat) / len(lat),
"lng": sum(lng) / len(lng),
})
return coordinates_aggregated
def set_namespace(self): def set_namespace(self):
namespace = self.gpx.getroot().nsmap.get(None) namespace = self.gpx.getroot().nsmap.get(None)
self.namespace = {'gpx': namespace} self.namespace = {'gpx': namespace}
@@ -22,47 +69,57 @@ class GPXParser:
return self.gpx.xpath(path, namespaces=self.namespace) return self.gpx.xpath(path, namespaces=self.namespace)
@cached() @cached()
async def get_times(self): async def get_times_all(self):
nodes = self.run_xpath("//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):
times = await self.get_times_all()
return await self.sample_times(times)
@cached() @cached()
async def get_coordinates(self) -> List[Dict[str, float]]: async def get_coordinates(self) -> List[Dict[str, float]]:
nodes = self.run_xpath("//gpx:trkpt") nodes = self.run_xpath("//gpx:trkpt")
return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes] return await self.average_coordinates(
[{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
)
@cached() @cached()
async def get_speed(self) -> List[int]: async def get_speed(self) -> List[float]:
nodes = self.run_xpath("//gpx:speed") nodes = self.run_xpath("//gpx:speed")
return [int(node.text) for node in nodes] return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached() @cached()
async def get_magnetic_variation(self) -> List[int]: async def get_magnetic_variation(self) -> List[float]:
nodes = self.run_xpath("//gpx:magvar") nodes = self.run_xpath("//gpx:magvar")
return [int(node.text) for node in nodes] return await self.average_sample_numbers([int(node.text) for node in nodes])
@cached() @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 [float(node.text) for node in nodes] return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached() @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 [float(node.text) for node in nodes] return await self.average_sample_numbers([float(node.text) for node in nodes])
@cached() @cached()
async def get_max_speed(self): async def get_max_speed(self):
return max(await self.get_speed()) return max(await self.get_speed()) or 0
@cached() @cached()
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:
return 0
return round(sum(speeds) / len(speeds), 2) return round(sum(speeds) / len(speeds), 2)
@cached() @cached()
async def get_max_altitude(self): async def get_max_altitude(self):
return max(await self.get_altitude()) return max(await self.get_altitude()) or 0
@cached() @cached()
async def get_avg_altitude(self): async def get_avg_altitude(self):
@@ -89,5 +146,4 @@ class GPXParser:
return self.gpx return self.gpx
def write(self, tree: _ElementTree, output: str): def write(self, tree: _ElementTree, output: str):
print(f"ZAPISUJI DO {output}")
tree.write(output) tree.write(output)
+6 -1
View File
@@ -46,7 +46,9 @@ class Weather:
resp.raise_for_status() resp.raise_for_status()
return await resp.json() return await resp.json()
async def get_weather_for_hour(self, date_time: datetime.datetime, gps: Tuple[float, float]) -> Dict[str, float|str]: async def get_weather_for_hour(
self, date_time: datetime.datetime, gps: Tuple[float, float]
) -> Dict[str, float | str]:
data = await self.download_weather_for_day(date_time.date(), gps) data = await self.download_weather_for_day(date_time.date(), gps)
# TODO: kontrola timezone! # TODO: kontrola timezone!
@@ -56,3 +58,6 @@ class Weather:
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")
return result_data return result_data
weather_api = Weather()
-13
View File
@@ -1,13 +0,0 @@
import asyncio
from external.elevation import ElevationAPI
from external.gpx_parser import GPXParser
async def test():
elevation_api = ElevationAPI()
parser = GPXParser("./uploads/tracks/37b979cb-71c0-4d09-a2c1-cfdad5f7a0cf-OK-AUR_28_AUR_Bristell_NG5_Zapisnik_letu_2023-08-04-00 00_2023-08-04-12 00(1).gpx")
points = await parser.get_coordinates()
elevation = await elevation_api.get_elevation_for_points(points)
parser.add_terrain_elevation(elevation)
asyncio.run(test())
@@ -1,16 +0,0 @@
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import Aircraft
async def load(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(Aircraft).filter(Aircraft.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
aircraft_dataloader = DataLoader(load_fn=load, cache=False)
-16
View File
@@ -1,16 +0,0 @@
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import Airport
async def load(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(Airport).filter(Airport.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id[id_] for id_ in ids]
airport_dataloader = DataLoader(load_fn=load, cache=False)
+113
View File
@@ -0,0 +1,113 @@
from collections import defaultdict
from typing import Type, List, Optional
from logger import log
from database import models, async_session
from database.query_builder import QueryBuilder
class BaseDataloader:
def __init__(
self,
model: Type[models.BaseModel],
relationship_column, filters: Optional[list] = None
):
super().__init__()
self.model = model
self.query_builder = QueryBuilder(self.model)
if relationship_column is None:
relationship_column = model.id
self.relationship_column = relationship_column
if filters is None:
filters = []
self.filters = filters
class SingleModelByIdDataloader(BaseDataloader):
async def load(self, ids: List[int]):
async with async_session() as session:
query = (
self.query_builder.get_simple_query(extra_select=[self.relationship_column], include_deleted=True)
.filter(self.relationship_column.in_(set(ids)))
.filter(*self.filters)
)
items = (await session.execute(query)).all()
items_by_id = {rel_id: item for item, rel_id in items}
return [items_by_id.get(id_) for id_ in ids]
class MultiModelsDataloader(BaseDataloader):
def __init__(
self,
model: Type[models.BaseModel],
relationship_column=None,
filters: Optional[list] = None,
extra_select: Optional[list] = None,
extra_join: Optional[list] = None,
order_by: Optional[list] = None,
):
super().__init__(model, relationship_column, filters)
if extra_join is None:
extra_join = []
self.extra_join = extra_join
if extra_select is None:
extra_select = []
self.extra_select = extra_select
if order_by is None:
order_by = [model.id.desc()] # defaultne radit od nejnovejsich zaznamu
self.order_by = order_by
def get_query(self, ids: list[int]):
query = (
self.query_builder.get_simple_query(
extra_select=[self.relationship_column] + self.extra_select,
order_by=self.order_by
)
.filter(self.relationship_column.in_(set(ids)))
.filter(*self.filters)
)
for joined_table in self.extra_join:
query = query.join(joined_table)
if self.filters:
query = query.filter(*self.filters)
return query
async def load(self, ids: List[int]):
query = self.get_query(ids)
async with async_session() as db:
data = (await db.execute(query)).all()
result_data = self.process_data(data)
return [result_data[id_] for id_ in ids]
def process_data(self, data):
result_data = defaultdict(list)
for row in data:
item, rel_id = row[0:2]
extra = row[2:]
if extra:
log.warning(f"Override function process_data, extra params={extra} are going to be discarded!")
result_data[rel_id].append(item)
return result_data
class FlightCopilotDataloader(MultiModelsDataloader):
def process_data(self, data):
result_data = defaultdict(list)
for row in data:
item, rel_id = row[0:2]
token = row[2]
item.token = token
result_data[rel_id].append(item)
return result_data
@@ -1,24 +0,0 @@
from collections import defaultdict
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import Copilot, Flight
async def load(ids: List[int]):
async with async_session() as session:
models = (await session.execute(
select(Copilot, Flight.id)
.join(Copilot.flights)
.filter(Flight.id.in_(ids))
)).all()
copilots_by_flight_id = defaultdict(list)
for copilot, flight_id in models:
copilots_by_flight_id[flight_id].append(copilot)
return [copilots_by_flight_id[id_] for id_ in ids]
flight_copilots_dataloader = DataLoader(load_fn=load, cache=False)
-40
View File
@@ -1,40 +0,0 @@
from collections import defaultdict
from typing import List, Optional
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import Flight, Copilot, PointOfInterest
class FlightsLoader:
def __init__(self, relationship_column, extra_join: Optional[list] = None):
if extra_join is None:
extra_join = []
self.relationship_column = relationship_column
self.extra_join = extra_join
async def load(self, ids: List[int]):
async with async_session() as session:
rel_column = self.relationship_column
query = (
select(Flight, rel_column)
.filter(rel_column.in_(ids))
)
for table in self.extra_join:
query = query.join(table)
data = (await session.execute(query)).all()
result_data = defaultdict(list)
for item, rel_id in data:
result_data[rel_id].append(item)
return [result_data[id_] for id_ in ids]
flights_by_copilot_dataloader = DataLoader(load_fn=FlightsLoader(Copilot.id, extra_join=[Flight.copilots]).load, cache=False)
flights_by_aircraft_dataloader = DataLoader(load_fn=FlightsLoader(Flight.aircraft_id).load, cache=False)
flight_by_poi_dataloader = DataLoader(load_fn=FlightsLoader(PointOfInterest.id, extra_join=[Flight.track, PointOfInterest]).load, cache=False)
@@ -0,0 +1,24 @@
from typing import List
from sqlalchemy import select, func
from strawberry.dataloader import DataLoader
from database import async_session, models
async def load_flight_durations(ids: List[int]):
async with async_session() as db:
flights = (await db.execute(
select(
models.Flight.id,
func.timediff(models.Flight.landing_datetime, models.Flight.takeoff_datetime).label("diff"),
func.coalesce(func.sum(models.FlightTrack.landing_duration), 0).label("landing_duration")
).join(models.Flight.track, isouter=True)
.group_by(models.Flight.id)
.filter(models.Flight.id.in_(ids))
)).all()
items_by_id = {item.id: item.diff.seconds // 60 - item.landing_duration for item in flights}
return [items_by_id.get(id_) for id_ in ids]
flight_duration_dataloader = DataLoader(load_fn=load_flight_durations, cache=False)
@@ -0,0 +1,152 @@
from strawberry.dataloader import DataLoader
from database import models
from graphql_schema.dataloaders.base import MultiModelsDataloader, FlightCopilotDataloader
aircrafts_from_organization_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Aircraft,
relationship_column=models.Organization.id,
extra_join=[models.Aircraft.organization]
).load,
cache=False
)
flight_copilots_dataloader = DataLoader(
load_fn=FlightCopilotDataloader(
models.Copilot,
relationship_column=models.Flight.id,
extra_select=[models.flight_has_copilot.c.token],
filters=[
models.flight_has_copilot.c.flight_id == models.Flight.id,
models.flight_has_copilot.c.copilot_id == models.Copilot.id,
],
extra_join=[models.Copilot.flights]).load,
cache=False)
flights_by_copilot_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.Copilot.id,
extra_join=[models.Flight.copilots],
order_by=[models.Flight.takeoff_datetime.desc()]
).load,
cache=False
)
public_flights_by_copilot_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.Copilot.id,
extra_join=[models.Flight.copilots],
filters=[models.Flight.is_public.is_(True)],
order_by=[models.Flight.takeoff_datetime.desc()]
).load,
cache=False
)
flights_by_aircraft_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.Flight.aircraft_id,
order_by=[models.Flight.takeoff_datetime.desc()]
).load, cache=False)
flight_by_poi_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.PointOfInterest.id,
order_by=[models.Flight.takeoff_datetime.desc()],
extra_join=[models.Flight.track, models.PointOfInterest]
).load,
cache=False
)
flights_by_event_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.Event.id,
extra_join=[models.Flight.event],
order_by=[models.Flight.takeoff_datetime.desc()]
).load,
cache=False
)
public_flights_by_event_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Flight,
relationship_column=models.Event.id,
filters=[models.Flight.is_public.is_(True)],
order_by=[models.Flight.takeoff_datetime.desc()],
extra_join=[models.Flight.event]
).load,
cache=False
)
user_organizations_dataloader = DataLoader(load_fn=MultiModelsDataloader(
models.Organization,
relationship_column=models.user_is_in_organization.c.user_id,
extra_join=[models.user_is_in_organization]
).load, cache=False)
users_in_organization_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.User,
relationship_column=models.Organization.id,
extra_join=[models.Organization.users]
).load,
cache=False
)
photos_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Photo,
relationship_column=models.Photo.flight_id,
order_by=[models.Photo.exposed_at]
).load,
cache=False
)
poi_photos_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Photo,
relationship_column=models.Photo.point_of_interest_id,
order_by=[models.Photo.exposed_at]
).load,
cache=False
)
flight_track_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.FlightTrack,
relationship_column=models.FlightTrack.flight_id,
order_by=[models.FlightTrack.order]
).load,
cache=False
)
copilots_in_photo_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Copilot,
relationship_column=models.copilot_has_photo.c.photo_id,
extra_join=[models.copilot_has_photo],
order_by=[models.Copilot.name]
).load,
cache=False
)
photo_copilots_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Photo,
relationship_column=models.copilot_has_photo.c.copilot_id,
extra_join=[models.copilot_has_photo],
order_by=[models.Photo.exposed_at]
).load,
cache=False
)
photos_aircraft_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
models.Photo,
relationship_column=models.Photo.aircraft_id,
order_by=[models.Photo.exposed_at]
).load,
cache=False
)
-42
View File
@@ -1,42 +0,0 @@
from collections import defaultdict
from typing import List, Literal
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import Photo
class PhotoDataloader:
def __init__(self, relationship_column: Literal['flight_id', 'point_of_interest_id']) -> None:
super().__init__()
self.relationship_column = relationship_column
async def load_collection(self, ids: List[int]):
async with async_session() as session:
models = (await session.scalars(
select(Photo)
.filter(getattr(Photo, self.relationship_column).in_(ids))
)).all()
photos_by_relationship_id = defaultdict(list)
for photo in models:
photos_by_relationship_id[getattr(photo, self.relationship_column)].append(photo)
return [photos_by_relationship_id[id_] for id_ in ids]
async def flight_cover_photo_load(ids: List[int]):
async with async_session() as session:
models = (
await session.scalars(
select(Photo)
.filter(Photo.is_flight_cover.is_(True))
.filter(Photo.flight_id.in_(ids)))
).all()
photos = {p.flight_id: p for p in models}
return [photos.get(id_) for id_ in ids]
cover_photo_loader = DataLoader(load_fn=flight_cover_photo_load, cache=False)
photos_dataloader = DataLoader(load_fn=PhotoDataloader("flight_id").load_collection, cache=False)
poi_photos_dataloader = DataLoader(load_fn=PhotoDataloader("point_of_interest_id").load_collection, cache=False)
-45
View File
@@ -1,45 +0,0 @@
from collections import defaultdict
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import PointOfInterest, FlightTrack, PointOfInterestType
async def load_flight_track(flight_ids: List[int]):
async with async_session() as session:
query = (
select(FlightTrack)
.filter(FlightTrack.flight_id.in_(flight_ids))
.order_by(FlightTrack.order)
)
data = (await session.scalars(query)).all()
pois_by_flight_id = defaultdict(list)
for poi in data:
pois_by_flight_id[poi.flight_id].append(poi)
return [pois_by_flight_id[id_] for id_ in flight_ids]
flight_track_dataloader = DataLoader(load_fn=load_flight_track, cache=False)
async def load_poi(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(PointOfInterest).filter(PointOfInterest.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
async def load_poi_type(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(PointOfInterestType).filter(PointOfInterestType.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
poi_dataloader = DataLoader(load_fn=load_poi, cache=False)
poi_type_dataloader = DataLoader(load_fn=load_poi_type, cache=False)
@@ -0,0 +1,24 @@
from typing import Optional, Type
from strawberry.dataloader import DataLoader
from database import models
from graphql_schema.dataloaders.base import SingleModelByIdDataloader
def create_dataloader(model: Type[models.BaseModel], relationship_column=None, filters: Optional[list] = None):
loader = SingleModelByIdDataloader(model, relationship_column, filters).load
return DataLoader(load_fn=loader, cache=False)
user_dataloader = create_dataloader(models.User)
airport_dataloader = create_dataloader(models.Airport)
aircraft_dataloader = create_dataloader(models.Aircraft)
event_dataloader = create_dataloader(models.Event)
organizations_dataloader = create_dataloader(models.Organization)
airport_weather_info_loader = create_dataloader(models.WeatherInfo)
poi_dataloader = create_dataloader(models.PointOfInterest)
poi_type_dataloader = create_dataloader(models.PointOfInterestType)
flight_dataloader = create_dataloader(models.Flight)
photo_adjustment_dataloader = create_dataloader(
models.PhotoAdjustment, relationship_column=models.PhotoAdjustment.photo_id
)
photo_dataloader = create_dataloader(models.Photo)
-16
View File
@@ -1,16 +0,0 @@
from typing import List
from sqlalchemy import select
from strawberry.dataloader import DataLoader
from database import async_session
from database.models import WeatherInfo
async def load(ids: List[int]):
async with async_session() as session:
models = (await session.scalars(select(WeatherInfo).filter(WeatherInfo.id.in_(ids)))).all()
models_by_id = {model.id: model for model in models}
return [models_by_id.get(id_) for id_ in ids]
airport_weather_info_loader = DataLoader(load_fn=load, cache=False)
+44 -89
View File
@@ -1,115 +1,70 @@
from typing import List, Optional, Annotated, TYPE_CHECKING from typing import Optional
import strawberry import strawberry
from strawberry.file_uploads import Upload from decorators.endpoints import authenticated_user_only, allow_public
from sqlalchemy import select from decorators.error_logging import error_logging
from database import models from .helpers.pagination import get_pagination_window, PaginationWindow
from decorators.endpoints import authenticated_user_only from .resolvers.aircraft import AircraftMutationResolver, AircraftQueryResolver
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input from graphql_schema.entities.types.mutation_input import CreateAircraftInput, EditAircraftInput
from upload_utils import handle_file_upload, delete_file, get_public_url from graphql_schema.entities.types.types import Aircraft
from ..dataloaders.flight import flights_by_aircraft_dataloader
if TYPE_CHECKING:
from .flight import Flight
AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/"
@strawberry_sqlalchemy_type(models.Aircraft)
class Aircraft:
async def load_flights(root):
return await flights_by_aircraft_dataloader.load(root.id)
photo_url: Optional[str] = strawberry.field(
resolver=lambda root: get_public_url(f"aircrafts/{root.photo_filename}") if root.photo_filename else None
)
flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field(resolver=load_flights)
def get_base_query(user_id: int):
return (
select(models.Aircraft)
.filter(models.Aircraft.created_by_id == user_id)
.filter(models.Aircraft.deleted.is_(False))
)
@strawberry.type @strawberry.type
class AircraftQueries: class AircraftQueries:
@strawberry.field() @strawberry.field()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def aircrafts(root, info) -> List[Aircraft]: async def aircrafts(root, info, limit: int, offset: int = 0) -> PaginationWindow[Aircraft]:
query = ( query = AircraftQueryResolver().get_query(
get_base_query(info.context.user_id) info.context.user_id,
.order_by(models.Aircraft.id.desc()) organization_ids=info.context.organization_ids
) )
return (await info.context.db.scalars(query)).all() return await get_pagination_window(
query=query,
item_type=Aircraft,
limit=limit,
offset=offset
)
@strawberry.field() @strawberry.field()
@authenticated_user_only() @error_logging
async def aircraft(root, info, id: int) -> Aircraft: @allow_public
query = ( async def aircraft(
get_base_query(info.context.user_id) root, info,
.filter(models.Aircraft.id == id) id: Optional[int] = None,
call_sign: Optional[str] = None,
public: Optional[bool] = False
) -> Aircraft:
filter_params = {}
if id:
filter_params['object_id'] = id
if call_sign:
filter_params['call_sign'] = call_sign
return await AircraftQueryResolver().get_one(
user_id=info.context.user_id,
organization_ids=info.context.organization_ids if not public else None,
only_public=public,
**filter_params
) )
return (await info.context.db.scalars(query)).one()
@strawberry.type @strawberry.type
class CreateAircraftMutation: class AircraftMutation:
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename'])
class CreateAircraftInput:
photo: Optional[Upload]
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft:
# TODO: kontrola organizace return await AircraftMutationResolver().create(info.context, input)
input_data = input.to_dict()
if input.photo:
input_data['photo_filename'] = await handle_file_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH)
return await models.Aircraft.create(
info.context.db,
data=dict(
**input_data,
created_by_id=info.context.user_id,
)
)
@strawberry.type
class EditAircraftMutation:
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['photo_filename'])
class EditAircraftInput:
photo: Optional[Upload]
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft: async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft:
# TODO: kontrola organizace return await AircraftMutationResolver().update(id, user_id=info.context.user_id, data=input)
# TODO: kontrola opravneni na akci
update_data = input.to_dict()
aircraft = await models.Aircraft.get_one(info.context.db, id)
if input.photo:
if aircraft.photo_filename:
delete_file(AIRCRAFT_UPLOAD_DEST_PATH + "/" + aircraft.photo_filename, silent=True)
update_data['photo_filename'] = await handle_file_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH)
return await models.Aircraft.update(info.context.db, obj=aircraft, data=update_data)
@strawberry.type
class DeleteAircraftMutation:
@strawberry.mutation @strawberry.mutation
@authenticated_user_only() @authenticated_user_only()
async def delete_aircraft(self, info, id: int) -> Aircraft: async def delete_aircraft(self, info, id: int) -> Aircraft:
# TODO: kontrola opravneni na akci return await AircraftMutationResolver().delete(info.context.user_id, id)
return await models.Aircraft.update(info.context.db, id=id, data=dict(deleted=True))
+11 -31
View File
@@ -1,45 +1,25 @@
from typing import List from typing import List
import strawberry import strawberry
from sqlalchemy import select, or_
from database import models from database import models
from decorators.error_logging import error_logging
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Airport
@strawberry_sqlalchemy_type(models.Airport)
class Airport:
pass
def get_base_query(user_id: int):
return (
select(models.Airport)
.filter(models.Airport.deleted.is_(False))
.filter(or_(
models.Airport.created_by_id == user_id,
models.Airport.created_by_id.is_(None),
))
)
@strawberry.type @strawberry.type
class AirportQueries: class AirportQueries:
@strawberry.field()
@strawberry.field @error_logging
@authenticated_user_only() @authenticated_user_only()
async def airports(root, info) -> List[Airport]: async def airports(root, info) -> List[Airport]:
query = ( return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id)
get_base_query(info.context.user_id)
.order_by(models.Airport.id.desc())
)
return (await info.context.db.scalars(query)).all() @strawberry.field()
@error_logging
@strawberry.field
@authenticated_user_only() @authenticated_user_only()
async def airport(root, info, id: int) -> Airport: async def airport(root, info, id: int) -> Airport:
query = ( return await BaseQueryResolver(Airport, models.Airport).get_one(
get_base_query(info.context.user_id) object_id=id,
.filter(models.Airport.id == id) user_id=info.context.user_id
) )
return (await info.context.db.scalars(query)).one()
+27
View File
@@ -0,0 +1,27 @@
from typing import List, Optional
import strawberry
from database import models
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Airspace
@strawberry.type
class AirspaceQueries:
@strawberry.field()
@error_logging
async def airspaces(
root, info, country: Optional[str] = None, types: Optional[list[str]] = None
) -> List[Airspace]:
filters = []
if country:
filters.append(models.AirspaceItem.country == country)
if types:
filters.append(models.AirspaceItem.type.in_(types))
return await BaseQueryResolver(Airspace, models.AirspaceItem).get_list(
info.context.user_id,
country=country,
filters=filters
)
+48 -62
View File
@@ -1,82 +1,68 @@
from typing import List, Annotated, TYPE_CHECKING from typing import List, Optional
import strawberry import strawberry
from sqlalchemy import select from graphql import GraphQLError
from strawberry.types import Info
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.error_logging import error_logging
from graphql_schema.dataloaders.flight import flights_by_copilot_dataloader from decorators.endpoints import authenticated_user_only, allow_public
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input from graphql_schema.entities.helpers.detail import get_detail_filters
from graphql_schema.entities.resolvers.base import BaseMutationResolver
if TYPE_CHECKING: from graphql_schema.entities.resolvers.copilot import CopilotQueryResolver
from .flight import Flight from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput
from graphql_schema.entities.types.types import Copilot
@strawberry_sqlalchemy_type(models.Copilot)
class Copilot:
async def load_flights(root):
return await flights_by_copilot_dataloader.load(root.id)
flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field(resolver=load_flights)
def get_base_query(user_id: int):
return (
select(models.Copilot)
.filter(models.Copilot.created_by_id == user_id)
.filter(models.Copilot.deleted.is_(False))
.order_by(models.Copilot.id.desc())
)
@strawberry.type @strawberry.type
class CopilotQueries: class CopilotQueries:
@strawberry.field() @strawberry.field()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def copilots(root, info) -> List[Copilot]: async def copilots(root, info: Info) -> List[Copilot]:
return (await info.context.db.scalars( return await CopilotQueryResolver().get_list(info.context.user_id)
get_base_query(info.context.user_id)
)).all()
@strawberry.field() @strawberry.field()
@authenticated_user_only() @error_logging
async def copilot(root, info, id: int) -> Copilot: @allow_public
return (await info.context.db.scalars( async def copilot(
get_base_query(info.context.user_id) root, info: Info,
.filter(models.Copilot.id == id) id: Optional[int] = None,
)).one() url_slug: Optional[str] = None,
pilot_username: Optional[str] = None,
upload_token: Optional[str] = None,
upload_flight_slug: Optional[str] = None,
public: Optional[bool] = False
) -> Copilot:
filter_params = {}
if id:
filter_params['object_id'] = id
if url_slug is not None:
filter_params['url_slug'] = url_slug
if upload_token and upload_flight_slug:
filter_params['upload_token'] = upload_token
filter_params['upload_flight_slug'] = upload_flight_slug
if pilot_username:
filter_params['pilot_username'] = pilot_username
if not filter_params:
raise GraphQLError(f"Invalid identification supplied: {filter_params}")
return await CopilotQueryResolver().get_one(
user_id=info.context.user_id,
only_public=public,
**filter_params
)
@strawberry.type @strawberry.type
class CreateCopilotMutation: class CopilotMutation:
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class CreateCopilotInput:
pass
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot: async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot:
input_data = input.to_dict() return await BaseMutationResolver(Copilot, models.Copilot).create(info.context, data=input)
return await models.Copilot.create(
info.context.db,
data=dict(
**input_data,
created_by_id=info.context.user_id,
)
)
@strawberry.type
class EditCopilotMutation:
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class EditCopilotInput:
pass
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot: async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot:
copilot = (await info.context.db.scalars( return await BaseMutationResolver(Copilot, models.Copilot).update(id, input, info.context.user_id)
get_base_query(info.context.user_id).filter(models.Copilot.id == id)
)).one()
return await models.Copilot.update(info.context.db, obj=copilot, data=input.to_dict())
+74
View File
@@ -0,0 +1,74 @@
from typing import Optional
import strawberry
from database import models
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.helpers.pagination import PaginationWindow, get_pagination_window
from graphql_schema.entities.resolvers.base import BaseMutationResolver
from graphql_schema.entities.resolvers.event import EventQueryResolver
from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput
from graphql_schema.entities.types.types import Event
@strawberry.type
class EventQueries:
@strawberry.field()
@error_logging
@allow_public
async def events(
root,
info,
limit: int,
offset: int = 0,
username: Optional[str] = None,
public: Optional[bool] = False,
) -> PaginationWindow[Event]:
query = EventQueryResolver().get_query(
user_id=info.context.user_id,
username=username,
order_by=[models.Event.date_from.desc(), models.Event.name.desc()],
only_public=public,
)
return await get_pagination_window(
query=query,
item_type=Event,
limit=limit,
offset=offset
)
@strawberry.field()
@error_logging
@allow_public
async def event(
root, info,
id: Optional[int] = None,
url_slug: Optional[str] = None,
username: Optional[str] = None,
public: Optional[bool] = False
) -> Event:
filter_params = get_detail_filters(id, url_slug)
if username:
filter_params['username'] = username
return await EventQueryResolver().get_one(
only_public=public,
user_id=info.context.user_id,
**filter_params
)
@strawberry.type
class EventMutation:
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def create_event(root, info, input: CreateEventInput) -> Event:
return await BaseMutationResolver(Event, models.Event).create(info.context, input)
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def edit_event(root, info, id: int, input: EditEventInput) -> Event:
return await BaseMutationResolver(Event, models.Event).update(id, input, info.context.user_id)
+58 -288
View File
@@ -1,317 +1,87 @@
import asyncio from typing import Optional
from datetime import timedelta, datetime
from typing import List, Optional, Annotated, TYPE_CHECKING, Tuple
import strawberry import strawberry
from fastapi import HTTPException from graphql import GraphQLError
from lxml import etree
from sqlalchemy import select, insert, delete from decorators.endpoints import authenticated_user_only, allow_public
from starlette.status import HTTP_401_UNAUTHORIZED
from strawberry.file_uploads import Upload
from database import models
from database.models import flight_has_copilot
from decorators.endpoints import authenticated_user_only
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from external.gpx_parser import GPXParser from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
from graphql_schema.dataloaders.aircraft import aircraft_dataloader from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput
from graphql_schema.dataloaders.airport import airport_dataloader from graphql_schema.entities.types.types import Flight
from graphql_schema.dataloaders.copilots import flight_copilots_dataloader from .helpers.detail import get_detail_filters
from graphql_schema.dataloaders.photos import photos_dataloader, cover_photo_loader from .helpers.pagination import PaginationWindow, get_pagination_window
from graphql_schema.dataloaders.poi import flight_track_dataloader, poi_dataloader
from graphql_schema.dataloaders.weather import airport_weather_info_loader
from graphql_schema.entities.aircraft import Aircraft
from graphql_schema.entities.airport import Airport
from graphql_schema.entities.photo import Photo
from graphql_schema.entities.poi import PointOfInterest
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
from upload_utils import get_public_url
from .helpers.flight import handle_aircraft_save, handle_track_edit, handle_copilots_edit, handle_weather_info, get_airports, handle_upload_gpx, handle_airport_changed, add_terrain_elevation
from ..types import ComboboxInput
if TYPE_CHECKING:
from .copilot import Copilot
@strawberry_sqlalchemy_type(models.FlightTrack)
class FlightTrack:
async def load_poi(root):
return await poi_dataloader.load(root.point_of_interest_id)
point_of_interest: PointOfInterest = strawberry.field(resolver=load_poi)
@strawberry_sqlalchemy_type(models.WeatherInfo)
class WeatherInfo:
pass
@strawberry.type
class Point:
lat: float
lng: float
@strawberry.type
class GPXTrack:
coordinates: List[Point]
speed: List[float]
altitude: List[float]
magnetic_variation: List[float]
terrain_elevation: List[float]
time: List[datetime]
max_speed: float
avg_speed: float
max_altitude: float
avg_altitude: float
@strawberry_sqlalchemy_type(models.Flight)
class Flight:
async def load_takeoff_airport(root):
return await airport_dataloader.load(root.takeoff_airport_id)
async def load_landing_airport(root):
return await airport_dataloader.load(root.landing_airport_id)
async def load_aircraft(root):
return await aircraft_dataloader.load(root.aircraft_id)
async def load_photos(root):
return await photos_dataloader.load(root.id)
async def load_cover_photo(root):
return await cover_photo_loader.load(root.id)
async def load_takeoff_weather_info(root):
return await airport_weather_info_loader.load(root.takeoff_weather_info_id)
async def load_landing_weather_info(root):
return await airport_weather_info_loader.load(root.landing_weather_info_id)
def duration_min_calculated(root):
if root.duration_total:
return root.duration_total
if root.takeoff_datetime and root.landing_datetime:
diff: timedelta = root.landing_datetime - root.takeoff_datetime
return diff.seconds / 60
return 0
async def load_track(root):
return await flight_track_dataloader.load(root.id)
async def load_gpx_track(root):
if not root.gpx_track_filename:
return None
try:
gpx_parser = GPXParser(f"/app/uploads/tracks/{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(),
)
def load_gpx_track_url(root):
if not root.gpx_track_filename:
return None
return get_public_url(f"/tracks/{root.gpx_track_filename}")
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
async def load_copilots(root):
return await flight_copilots_dataloader.load(root.id)
duration_min_calculated: int = strawberry.field(resolver=duration_min_calculated)
copilots: Optional[List[Annotated["Copilot", strawberry.lazy(".copilot")]]] = strawberry.field(resolver=load_copilots)
aircraft: Aircraft = strawberry.field(resolver=load_aircraft)
takeoff_airport: Airport = strawberry.field(resolver=load_takeoff_airport)
landing_airport: Airport = strawberry.field(resolver=load_landing_airport)
cover_photo: Optional[Photo] = strawberry.field(resolver=load_cover_photo)
track: List[FlightTrack] = strawberry.field(resolver=load_track)
takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_takeoff_weather_info)
landing_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_landing_weather_info)
photos: List[Photo] = strawberry.field(resolver=load_photos)
gpx_track_url: Optional[str] = strawberry.field(resolver=load_gpx_track_url) # TODO: odstranit
gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track)
def get_base_query(user_id: Optional[int], username: Optional[str] = None, is_auth: bool = False):
query = (
select(models.Flight)
.filter(models.Flight.deleted.is_(False))
.order_by(models.Flight.takeoff_datetime.desc())
)
if user_id:
query = query.filter(models.Flight.created_by_id == user_id)
if username:
query = query.join(models.Flight.created_by).filter(models.User.public_username == username)
if not is_auth:
query = query.filter(models.Flight.is_public.is_(True))
return query
@strawberry.type @strawberry.type
class FlightQueries: class FlightQueries:
@strawberry.field() @strawberry.field()
async def flights(root, info, username: Optional[str] = None) -> List[Flight]: @error_logging
if not info.context.user_id and not username: @allow_public
raise HTTPException(HTTP_401_UNAUTHORIZED, f"user_id={info.context.user_id}, {username=}") async def flights(
root, info,
limit: int,
offset: int = 0,
username: Optional[str] = None,
event_id: Optional[int] = None,
public: Optional[bool] = False,
copilot_id: Optional[int] = None,
point_of_interest_id: Optional[int] = None,
aircraft_id: Optional[int] = None,
) -> PaginationWindow[Flight]:
query = FlightQueryResolver().get_query(
user_id=info.context.user_id,
username=username,
event_id=event_id,
only_public=public,
copilot_id=copilot_id,
aircraft_id=aircraft_id,
point_of_interest_id=point_of_interest_id
)
query = ( return await get_pagination_window(
get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) query=query,
.order_by(models.Flight.id.desc()) item_type=Flight,
limit=limit,
offset=offset,
) )
return (await info.context.db.scalars(query)).all()
@strawberry.field() @strawberry.field()
@error_logging @error_logging
async def flight(root, info, id: int, username: Optional[str] = None) -> Flight: @allow_public
if not info.context.user_id and not username: async def flight(
raise HTTPException(HTTP_401_UNAUTHORIZED) root, info,
id: Optional[int] = None,
url_slug: Optional[str] = None,
username: Optional[str] = None,
public: Optional[bool] = False
) -> Flight:
filter_params = get_detail_filters(id, url_slug)
if username:
filter_params['username'] = username
query = ( return await FlightQueryResolver().get_one(
get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) user_id=info.context.user_id,
.filter(models.Flight.id == id) only_public=public,
**filter_params
) )
return (await info.context.db.scalars(query)).one()
@strawberry.type @strawberry.type
class CreateFlightMutation: class FlightMutation:
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=[
"id", "aircraft_id", "landing_airport_id", "takeoff_airport_id", "weather_info_takeoff_id",
"weather_info_landing_id", "with_instructor"
])
class CreateFlightInput:
aircraft: ComboboxInput
landing_airport: ComboboxInput
takeoff_airport: ComboboxInput
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def create_flight(self, info, input: CreateFlightInput) -> Flight: async def create_flight(self, info, input: CreateFlightInput) -> Flight:
db = info.context.db return await FlightMutationResolver().create(info.context, input)
data = input.to_dict()
takeoff_airport, landing_airport = await get_airports(db, input.takeoff_airport.id, input.landing_airport.id)
aircraft_id = await handle_aircraft_save(db, info.context.user_id, input.aircraft)
weather_takeoff, weather_landing = await asyncio.gather(
handle_weather_info(db, data['takeoff_datetime'], takeoff_airport),
handle_weather_info(db, data['landing_datetime'], landing_airport)
)
await db.flush()
flight = await models.Flight.create(db, data={
**data,
"takeoff_weather_info_id": weather_takeoff.id,
"landing_weather_info_id": weather_landing.id,
"takeoff_airport_id": takeoff_airport.id,
"landing_airport_id": landing_airport.id,
"aircraft_id": aircraft_id,
"created_by_id": info.context.user_id
})
return flight
@strawberry.type
class EditFlightMutation:
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=[
"id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id",
"takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename"
], all_optional=True)
class EditFlightInput:
gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
track: Optional[List[ComboboxInput]] = None
copilots: Optional[List[ComboboxInput]] = None
aircraft: Optional[ComboboxInput] = None
landing_airport: Optional[ComboboxInput] = None
takeoff_airport: Optional[ComboboxInput] = None
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight: async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight:
db = info.context.db return await FlightMutationResolver().update(info.context, id, input)
user_id = info.context.user_id
flight = (await db.scalars(
get_base_query(user_id=user_id, is_auth=bool(user_id)).filter(models.Flight.id == id)
)).one()
takeoff_airport, landing_airport = await get_airports(
db,
takeoff_airport_id=input.takeoff_airport.id if input.takeoff_airport else flight.takeoff_airport_id,
landing_airport_id=input.landing_airport.id if input.landing_airport else flight.landing_airport_id,
)
data = input.to_dict()
if input.gpx_track is not None:
data['gpx_track_filename'] = await handle_upload_gpx(flight, input.gpx_track)
info.context.background_tasks.add_task(add_terrain_elevation, flight=flight, gpx_filename=data['gpx_track_filename'], db=db)
# TODO: nasledujici metody volat i pokud se zmenil cas vzletu!
if input.takeoff_airport and input.takeoff_airport.id != flight.takeoff_airport_id:
await handle_airport_changed(
db,
flight,
takeoff_airport,
type_="takeoff",
input_datetime=data.get('takeoff_datetime')
)
if input.landing_airport and input.landing_airport.id != flight.landing_airport_id:
await handle_airport_changed(
db,
flight,
landing_airport,
type_="landing",
input_datetime=data.get('landing_datetime')
)
if input.aircraft is not None:
data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft)
if input.track is not None:
await handle_track_edit(db=db, flight=flight, track=input.track, user_id=user_id)
copilots = await handle_copilots_edit(db, input.copilots or [], user_id)
await db.execute(delete(flight_has_copilot).filter_by(flight_id=flight.id))
for copilot_id in copilots:
await db.execute(insert(flight_has_copilot).values(flight_id=flight.id, copilot_id=copilot_id))
return await models.Flight.update(db, obj=flight, data=data)
@strawberry.type
class DeleteFlightMutation:
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def delete_flight(self, info, id: int) -> Flight: async def delete_flight(self, info, id: int) -> Flight:
flight = ( return await FlightMutationResolver().delete(info.context.user_id, id)
(await info.context.db.scalars(
get_base_query(user_id=info.context.user_id, is_auth=True)
.filter(models.Flight.id == id))
)
.one()
)
flight.deleted = True
return flight
@@ -0,0 +1,29 @@
from typing import Type, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from database import models
from graphql_schema.entities.types.mutation_input import ComboboxInput
async def handle_combobox_save(
db: AsyncSession,
model: Type[models.BaseModel],
input: ComboboxInput,
user_id: int,
name_column: str = "name",
extra_data: Optional[dict] = None
) -> int:
if input.id:
return input.id
else:
if not extra_data:
extra_data = {}
# TODO: vyhledat podle `name_column` existujici zaznam, pokud nebude, az pak vytvorit
data = {name_column: input.name, **extra_data}
if hasattr(model, "created_by_id"):
data["created_by_id"] = user_id
obj = await model.create(db, data)
await db.flush()
return obj.id
@@ -0,0 +1,15 @@
from typing import Optional
from graphql import GraphQLError
def get_detail_filters(id: Optional[int], url_slug: Optional[str]) -> dict:
filter_params = {}
if id:
filter_params['object_id'] = id
if url_slug is not None:
filter_params['url_slug'] = url_slug
if not filter_params:
raise GraphQLError("You must specifiy either urlSlug or id!")
return filter_params
@@ -1,176 +0,0 @@
import asyncio
from datetime import datetime
from typing import List, Type, Literal, Optional, Tuple
from aiohttp import ClientResponseError
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTasks
from strawberry.file_uploads import Upload
from database import models
from external.elevation import ElevationAPI
from external.gpx_parser import GPXParser
from external.weather import Weather
from graphql_schema.types import ComboboxInput
from upload_utils import delete_file, file_exists, handle_file_upload
weather_api = Weather()
async def handle_weather_info(db: AsyncSession, date_time: datetime, airport: models.Airport) -> models.WeatherInfo:
weather = await weather_api.get_weather_for_hour(date_time.astimezone(), (airport.gps_latitude, airport.gps_longitude))
model = models.WeatherInfo(**{
"datetime": weather['datetime'],
"qnh": weather['pressure_msl'],
"temperature_surface": weather['temperature_2m'],
"dewpoint_surface": weather['dewpoint_2m'],
"rain": weather['rain'],
"cloudcover_total": weather['cloudcover'],
"cloudcover_low": weather['cloudcover_low'],
"wind_speed_surface": weather['windspeed_10m'],
"wind_direction_surface": weather['winddirection_10m'],
})
db.add(model)
return model
async def handle_track_edit(db: AsyncSession, flight: models.Flight, track: List[ComboboxInput], user_id: int):
await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight.id))
existing_poi_ids = [i.id for i in track if i.id]
poi_query = (
select(models.PointOfInterest)
.filter(models.PointOfInterest.created_by_id == user_id)
.filter(models.PointOfInterest.id.in_(existing_poi_ids))
)
pois = (await db.scalars(poi_query)).all()
poi_map = {poi.id: poi for poi in pois}
order = 0
for item in track:
poi_object = None
if item.id:
poi_object = poi_map.get(item.id)
if not poi_object:
poi_object = await models.PointOfInterest.create(db, data=dict(created_by_id=user_id, name=item.name, description=""))
await db.flush()
await models.FlightTrack.create(
db,
data={
"flight_id": flight.id,
"point_of_interest_id": poi_object.id,
"order": order
}
)
order += 1
async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: ComboboxInput):
return await handle_combobox_save(
db, models.Aircraft, aircraft, user_id,
name_column="call_sign",
extra_data={
"description": "",
"model": "",
"seats": 2,
"manufacturer": "",
})
async def get_airports(db, takeoff_airport_id: int, landing_airport_id: int) -> Tuple[models.Airport, models.Airport]:
takeoff_airport = (await db.scalars(
select(models.Airport).filter(models.Airport.id == takeoff_airport_id)
)).one()
if takeoff_airport_id == landing_airport_id:
landing_airport = takeoff_airport
else:
landing_airport = (await db.scalars(
select(models.Airport).filter(models.Airport.id == landing_airport_id)
)).one()
return takeoff_airport, landing_airport
async def handle_airport_changed(
db, flight: models.Flight, airport: models.Airport, type_: Literal['takeoff', 'landing'],
input_datetime: Optional[datetime]
):
flight_datetime = getattr(flight, f"{type_}_datetime")
if input_datetime and input_datetime != flight_datetime:
weather = await handle_weather_info(db, input_datetime, airport)
existing_weather_id = getattr(flight, f"{type_}_weather_info_id")
if existing_weather_id:
# db.delete(delete())
pass
setattr(flight, f"{type_}_weather_info_id", weather.id)
setattr(flight, f"{type_}_airport_id", airport.id)
setattr(flight, f"{type_}_datetime", input_datetime)
async def add_terrain_elevation(db: AsyncSession, flight: models.Flight, gpx_filename: str):
path = "/app/uploads/tracks" # TODO vytahnout do configu
elevation_api = ElevationAPI()
gpx_parser = GPXParser(f"{path}/{gpx_filename}")
coordinates = await gpx_parser.get_coordinates()
print("AAAAAAAAAAAAAAAAAAAAAAAAA", coordinates)
try:
elevation = await elevation_api.get_elevation_for_points(coordinates)
print("ELEVATION", elevation)
tree_with_elevation = gpx_parser.add_terrain_elevation(elevation)
output_name = f"terrain_{gpx_filename}"
gpx_parser.write(tree_with_elevation, f"{path}/{output_name}")
await models.Flight.update(db, {"gpx_track_filename": output_name}, obj=flight)
except ClientResponseError:
print("NEumim elevation!")
async def handle_upload_gpx(flight: models.Flight, gpx_track: Upload):
path = "/app/uploads/tracks"
if flight.gpx_track_filename:
delete_file(path + "/" + flight.gpx_track_filename, silent=True)
return await handle_file_upload(gpx_track, path)
async def handle_copilots_edit(db: AsyncSession, copilots: List[ComboboxInput], user_id: int) -> List[int]:
cors = [handle_combobox_save(db, models.Copilot, copilot, user_id) for copilot in copilots]
return await asyncio.gather(*cors)
async def handle_combobox_save(
db: AsyncSession,
model: Type[models.BaseModel],
input: ComboboxInput,
user_id: int,
name_column: str = "name",
extra_data: Optional[dict] = None
) -> int:
if input.id:
return input.id
else:
if not extra_data:
extra_data = {}
data = {name_column: input.name, **extra_data}
if hasattr(model, "created_by_id"):
data["created_by_id"] = user_id
obj = await model.create(db, data)
await db.flush()
return obj.id
@@ -0,0 +1,43 @@
from typing import TypeVar, Generic, List
import strawberry
from sqlalchemy import func, Select
from database.transaction import get_session
Item = TypeVar("Item")
@strawberry.type
class PaginationWindow(Generic[Item]):
items: List[Item] = strawberry.field(
description="The list of items in this pagination window."
)
total_items_count: int = strawberry.field(
description="Total number of items in the filtered dataset."
)
async def get_pagination_window(
query: Select,
item_type: type,
limit: int,
offset: int = 0,
) -> PaginationWindow:
if limit <= 0:
raise Exception(f"limit ({limit}) must be > 0")
async with get_session() as db:
cnt_query = query.with_only_columns(func.count())
total_items_count = (await db.scalars(cnt_query)).one()
# if offset != 0 and not 0 <= offset < total_items_count:
# raise Exception(f"offset ({offset}) is out of range " f"(0-{total_items_count - 1})")
async with get_session() as db:
data = (await db.scalars(query.limit(limit).offset(offset))).all()
dataset = [item_type(**i.as_dict()) for i in data]
return PaginationWindow(
items=dataset,
total_items_count=total_items_count
)
@@ -0,0 +1,91 @@
from typing import List
import strawberry
from sqlalchemy import delete
from sqlalchemy.dialects.mysql import insert
from sqlalchemy.exc import IntegrityError
from database import models
from decorators.endpoints import authenticated_user_only
from database.transaction import get_session
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.base import BaseMutationResolver
from graphql_schema.entities.resolvers.organization import OrganizationQueryResolver
from graphql_schema.entities.types.mutation_input import CreateOrganizationInput, EditOrganizationInput
from graphql_schema.entities.types.types import Organization
@strawberry.type
class OrganizationQueries:
@strawberry.field()
@error_logging
@authenticated_user_only()
async def organizations(root, info) -> List[Organization]:
return await OrganizationQueryResolver().get_list()
@strawberry.field()
@error_logging
@authenticated_user_only()
async def organization(root, info, id: int) -> Organization:
return await OrganizationQueryResolver().get_one(object_id=id)
@strawberry.type
class OrganizationMutation:
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def create_organization(root, info, input: CreateOrganizationInput) -> Organization:
return await BaseMutationResolver(Organization, models.Organization).create(info.context, data=input)
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def edit_organization(root, info, id: int, input: EditOrganizationInput) -> Organization:
return await BaseMutationResolver(Organization, models.Organization).update(
id,
data=input,
user_id=info.context.user_id
)
@strawberry.type
class OrganizationUserMutation:
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def add_to_organization(root, info, organization_id: int) -> Organization:
async with get_session() as db:
organization = (await db.scalars(
OrganizationQueryResolver().get_query(object_id=organization_id)
)).one()
try:
await db.execute(
insert(models.user_is_in_organization).values(
user_id=info.context.user_id,
organization_id=organization_id
)
)
except IntegrityError:
pass
return Organization(**organization.as_dict())
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def remove_from_organization(root, info, organization_id: int) -> Organization:
async with get_session() as db:
organization = (await db.scalars(
OrganizationQueryResolver().get_query(object_id=organization_id)
)).one()
await db.execute(
delete(models.user_is_in_organization).filter_by(
user_id=info.context.user_id,
organization_id=organization_id
)
)
return Organization(**organization.as_dict())
+68 -134
View File
@@ -1,156 +1,90 @@
from typing import List, Optional, Annotated, TYPE_CHECKING from typing import List, Optional
import strawberry import strawberry
from sqlalchemy import select, update
from strawberry.file_uploads import Upload
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public, raise_unauthorized
from graphql_schema.dataloaders.poi import poi_dataloader from decorators.error_logging import error_logging
from graphql_schema.entities.poi import PointOfInterest from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver
from graphql_schema.types import ComboboxInput from graphql_schema.entities.types.types import Photo
from upload_utils import get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists, resize_image from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput
from .helpers.flight import handle_combobox_save
if TYPE_CHECKING:
from .poi import PointOfInterest
@strawberry_sqlalchemy_type(models.Photo)
class Photo:
def resolve_url(root):
return get_public_url(f"photos/{root.flight_id}/{root.filename}")
def resolve_thumb_url(root):
thumbnail = get_photo_basepath(root.flight_id) + "/thumbs/" + root.filename
if not file_exists(thumbnail):
return get_public_url(f"photos/{root.flight_id}/{root.filename}")
return get_public_url(f"photos/{root.flight_id}/thumbs/{root.filename}")
async def load_poi(root):
if not root.point_of_interest_id:
return None
return await poi_dataloader.load(root.point_of_interest_id)
url: str = strawberry.field(resolver=resolve_url)
thumbnail_url: str = strawberry.field(resolver=resolve_thumb_url)
point_of_interest: Optional[Annotated["PointOfInterest", strawberry.lazy('.poi')]] = strawberry.field(resolver=load_poi)
def get_base_query(user_id: int):
return (
select(models.Photo)
.filter(models.Photo.created_by_id == user_id)
.order_by(models.Photo.id.desc())
)
def get_photo_basepath(flight_id: int) -> str:
return f"/app/uploads/photos/{flight_id}"
@strawberry.type @strawberry.type
class PhotoQueries: class PhotoQueries:
@strawberry.field() @strawberry.field()
async def photos(root, info) -> List[Photo]: @error_logging
query = get_base_query(info.context.user_id) @allow_public
return (await info.context.db.scalars(query)).all() async def photos(
root, info,
flight_id: Optional[int] = None,
copilot_id: Optional[int] = None,
uploaded_by_copilot_id: Optional[int] = None,
point_of_interest_id: Optional[int] = None,
aircraft_id: Optional[int] = None,
public: Optional[bool] = False,
) -> List[Photo]:
return await PhotoQueryResolver().get_list(
public=public,
flight_id=flight_id,
user_id=info.context.user_id,
copilot_id=copilot_id,
uploaded_by_copilot_id=uploaded_by_copilot_id,
aircraft_id=aircraft_id,
point_of_interest_id=point_of_interest_id,
order_by=[models.Photo.exposed_at]
)
@strawberry.field()
@error_logging
@allow_public
async def photo(root, info, id: int, public: Optional[bool] = False, ) -> Photo:
return await BaseQueryResolver(Photo, models.Photo).get_one(
object_id=id,
user_id=info.context.user_id,
only_public=public
)
@strawberry.type @strawberry.type
class UploadPhotoMutation: class PhotoMutation:
@strawberry.input
class UploadPhotoInput:
photo: Upload
flight_id: int
name: Optional[str] = None
description: Optional[str] = None
point_of_interest: Optional[ComboboxInput] = None
@strawberry.mutation @strawberry.mutation
@authenticated_user_only() @error_logging
async def upload_photo(self, info, input: UploadPhotoInput) -> Photo: async def upload_photo(self, info, input: UploadPhotoInput) -> Photo:
path = get_photo_basepath(input.flight_id) if info.context.user_id is None and not input.copilot_upload_token:
filename = await handle_file_upload(input.photo, path) raise_unauthorized()
info.context.background_tasks.add_task(resize_image, path=path, filename=filename, new_width=2500) return await PhotoMutationResolver().upload(info, input)
info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename)
exif_info = await parse_exif_info(path, filename)
return await models.Photo.create(data={
"flight_id": input.flight_id,
"name": input.name,
"filename": filename,
"description": input.description,
"exposed_at": exif_info.get("datetime"),
"gps_latitude": exif_info.get("gps_latitude"),
"gps_longitude": exif_info.get("gps_longitude"),
"gps_altitude": exif_info.get("gps_altitude"),
"is_flight_cover": False,
"created_by_id": info.context.user_id,
}, db_session=info.context.db)
@strawberry.type
class EditPhotoMutation:
@strawberry.input
class EditPhotoInput:
name: Optional[str] = None
description: Optional[str] = None
point_of_interest: Optional[ComboboxInput] = None
is_flight_cover: Optional[bool] = None
@strawberry.mutation() @strawberry.mutation()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def edit_photo(self, info, id: int, input: EditPhotoInput) -> Photo: async def edit_photo(self, info, id: int, input: EditPhotoInput) -> Photo:
query = get_base_query(info.context.user_id) return await PhotoMutationResolver().update(id, input, info.context.user_id)
photo = (await info.context.db.scalars(query.filter(models.Photo.id == id))).one()
data = {
key: getattr(input, key) for key in ('name', 'description', 'is_flight_cover')
if getattr(input, key) is not None
}
if input.point_of_interest:
data['point_of_interest_id'] = await handle_combobox_save(
info.context.db,
models.PointOfInterest,
input.point_of_interest,
info.context.user_id,
extra_data={
"description": ""
}
)
updated_model = await models.Photo.update(info.context.db, obj=photo, data=data)
if input.is_flight_cover:
# reset other covers
(await info.context.db.execute(
update(models.Photo)
.filter(models.Photo.flight_id == photo.flight_id)
.filter(models.Photo.id != id).values(is_flight_cover=False))
)
return updated_model
@strawberry.type
class DeletePhotoMutation:
@strawberry.mutation() @strawberry.mutation()
@error_logging
async def change_orientation(self, info, id: int, direction: str, copilot_upload_token: Optional[str] = None) -> Photo:
if info.context.user_id is None and not copilot_upload_token:
raise_unauthorized()
return await PhotoMutationResolver().change_orientation(
id=id,
user_id=info.context.user_id,
copilot_upload_token=copilot_upload_token,
direction=direction,
info=info
)
@strawberry.mutation()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def delete_photo(self, info, id: int) -> Photo: async def adjust_photo(self, info, id: int, adjustment: AdjustmentInput) -> Photo:
query = get_base_query(info.context.user_id) return await PhotoMutationResolver().adjust(id, info=info, user_id=info.context.user_id, adjustment=adjustment)
photo = (await info.context.db.scalars(query.filter(models.Photo.id == id))).one()
base_path = get_photo_basepath(photo.flight_id) @strawberry.mutation()
try: @error_logging
delete_file(f"{base_path}/{photo.filename}") async def delete_photo(self, info, id: int, copilot_upload_token: Optional[str] = None) -> Photo:
delete_file(f"{base_path}/thumbs/{photo.filename}") if info.context.user_id is None and not copilot_upload_token:
except Exception as e: raise_unauthorized()
print(e)
await info.context.db.delete(photo) return await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id)
return photo
+63 -101
View File
@@ -1,138 +1,100 @@
from typing import List, Optional, TYPE_CHECKING, Annotated from typing import Optional
import strawberry import strawberry
from sqlalchemy import select, or_
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public
from graphql_schema.dataloaders.flight import flight_by_poi_dataloader from database.transaction import get_session
from graphql_schema.dataloaders.photos import poi_photos_dataloader from decorators.error_logging import error_logging
from graphql_schema.dataloaders.poi import poi_type_dataloader from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.helpers.flight import handle_combobox_save from graphql_schema.entities.helpers.detail import get_detail_filters
from graphql_schema.entities.poi_type import PointOfInterestType from graphql_schema.entities.helpers.pagination import get_pagination_window, PaginationWindow
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver
from graphql_schema.types import ComboboxInput from graphql_schema.entities.types.types import PointOfInterest
from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput
if TYPE_CHECKING:
from .flight import Flight
from .photo import Photo
@strawberry_sqlalchemy_type(models.PointOfInterest)
class PointOfInterest:
async def load_photos(root):
return await poi_photos_dataloader.load(root.id)
async def load_type(root):
return await poi_type_dataloader.load(root.type_id)
async def load_flights(root):
return await flight_by_poi_dataloader.load(root.id)
type: Optional[PointOfInterestType] = strawberry.field(resolver=load_type)
photos: List[Annotated["Photo", strawberry.lazy('.photo')]] = strawberry.field(resolver=load_photos)
flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field(resolver=load_flights)
def get_base_query(user_id: int, only_my: bool = False):
query = (
select(models.PointOfInterest)
.filter(models.PointOfInterest.deleted.is_(False))
)
if only_my:
query = query.filter(models.PointOfInterest.created_by_id == user_id)
else:
query = query.filter(or_(
models.PointOfInterest.created_by_id == user_id,
models.PointOfInterest.is_public.is_(True)
))
return query
@strawberry.type @strawberry.type
class PointOfInterestQueries: class PointOfInterestQueries:
@strawberry.field() @strawberry.field()
@authenticated_user_only() @error_logging
async def points_of_interest(root, info) -> List[PointOfInterest]: @allow_public
query = ( async def points_of_interest(
get_base_query(info.context.user_id) root, info,
.order_by(models.PointOfInterest.id.desc()) limit: int, offset: int = 0,
search: Optional[str] = None,
public: bool = False
) -> PaginationWindow[PointOfInterest]:
query = BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_query(
info.context.user_id,
only_my=bool(info.context.user_id),
include_others_public=True,
only_public=public,
search=search,
)
return await get_pagination_window(
query=query,
item_type=PointOfInterest,
limit=limit,
offset=offset
) )
return (await info.context.db.scalars(query)).all()
@strawberry.field() @strawberry.field()
@authenticated_user_only() @allow_public
async def point_of_interest(root, info, id: int) -> PointOfInterest: async def point_of_interest(
query = ( root, info,
get_base_query(info.context.user_id) url_slug: Optional[str] = None,
.filter(models.PointOfInterest.id == id) id: Optional[int] = None,
public: bool = False
) -> PointOfInterest:
filter_params = get_detail_filters(id, url_slug)
return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_one(
user_id=info.context.user_id,
only_public=public,
**filter_params
) )
return (await info.context.db.scalars(query)).one()
@strawberry.type @strawberry.type
class CreatePointOfInterestMutation: class PointOfInterestMutation:
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'])
class CreatePointOfInterestInput:
type: Optional[ComboboxInput] = None
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def create_point_of_interest(root, info, input: CreatePointOfInterestInput) -> PointOfInterest: async def create_point_of_interest(root, info, input: CreatePointOfInterestInput) -> PointOfInterest:
input_data = input.to_dict() input_data = input.to_dict()
async with get_session() as db:
if input.type: if input.type:
input_data['type_id'] = await handle_combobox_save( input_data['type_id'] = await handle_combobox_save(
info.context.db, db, models.PointOfInterestType, input.type, info.context.user_id
models.PointOfInterestType,
input.type,
info.context.user_id
) )
return await models.PointOfInterest.create( input_data['created_by_id'] = info.context.user_id
info.context.db, return await BaseMutationResolver(PointOfInterest, models.PointOfInterest)._do_create(
data=dict( db, input_data
**input_data,
created_by_id=info.context.user_id,
) )
)
@strawberry.type
class EditPointOfInterestMutation:
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'])
class EditPointOfInterestInput:
type: Optional[ComboboxInput] = None
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def edit_point_of_interest(root, info, id: int, input: EditPointOfInterestInput) -> PointOfInterest: async def edit_point_of_interest(root, info, id: int, input: EditPointOfInterestInput) -> PointOfInterest:
# TODO: kontrola organizace
input_data = input.to_dict() input_data = input.to_dict()
if input.type is not None: query = BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_query(
input_data['type_id'] = await handle_combobox_save( user_id=info.context.user_id, object_id=id, only_public=False
info.context.db,
models.PointOfInterestType,
input.type,
info.context.user_id
) )
poi = ( async with get_session() as db:
await info.context.db.scalars( if input.type is not None:
get_base_query(info.context.user_id, only_my=True) input_data['type_id'] = await handle_combobox_save(
.filter(models.PointOfInterest.id == id)) db, models.PointOfInterestType, input.type, info.context.user_id
).one() )
return await models.PointOfInterest.update(info.context.db, obj=poi, data=input_data)
poi = (await db.scalars(query)).one()
@strawberry.type updated_poi = await models.PointOfInterest.update(db, obj=poi, data=input_data)
class DeletePointOfInterestMutation: return PointOfInterest(**updated_poi.as_dict())
@strawberry.mutation @strawberry.mutation
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def delete_point_of_interest(self, info, id: int) -> PointOfInterest: async def delete_point_of_interest(self, info, id: int) -> PointOfInterest:
poi = get_base_query(info.context.user_id, only_my=True).filter(models.PointOfInterest.id == id).one() return await BaseMutationResolver(PointOfInterest, models.PointOfInterest).delete(info.context.user_id, id=id)
return await models.PointOfInterest.update(info.context.db, obj=poi, data=dict(deleted=True))
+10 -36
View File
@@ -1,55 +1,29 @@
from typing import List, Optional from typing import List
import strawberry import strawberry
from sqlalchemy import select, or_
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from decorators.error_logging import error_logging
from graphql_schema.types import ComboboxInput from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import PointOfInterestType
@strawberry_sqlalchemy_type(models.PointOfInterestType)
class PointOfInterestType:
pass
def get_base_query(user_id: int, only_my: bool = False):
query = (
select(models.PointOfInterestType)
.filter(models.PointOfInterestType.deleted.is_(False))
)
if only_my:
query = query.filter(models.PointOfInterestType.created_by_id == user_id)
else:
query = query.filter(or_(
models.PointOfInterestType.created_by_id == user_id,
models.PointOfInterestType.is_public.is_(True)
))
return query
@strawberry.type @strawberry.type
class PointOfInterestTypeQueries: class PointOfInterestTypeQueries:
@strawberry.field() @strawberry.field()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def point_of_interest_types(root, info) -> List[PointOfInterestType]: async def point_of_interest_types(root, info) -> List[PointOfInterestType]:
query = ( return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_list(info.context.user_id)
get_base_query(info.context.user_id, only_my=False)
.order_by(models.PointOfInterestType.id.desc())
)
return (await info.context.db.scalars(query)).all()
@strawberry.field() @strawberry.field()
@error_logging
@authenticated_user_only() @authenticated_user_only()
async def point_of_interest_type(root, info, id: int) -> PointOfInterestType: async def point_of_interest_type(root, info, id: int) -> PointOfInterestType:
query = ( return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_one(
get_base_query(info.context.user_id) object_id=id,
.filter(models.PointOfInterestType.id == id) user_id=info.context.user_id
) )
return (await info.context.db.scalars(query)).one()
# #
# @strawberry.type # @strawberry.type
@@ -0,0 +1,91 @@
from operator import or_
from typing import Set, Optional
from sqlalchemy import and_
from database import models
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.types.mutation_input import EditAircraftInput, CreateAircraftInput
from graphql_schema.entities.types.types import Aircraft
class AircraftQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(graphql_type=Aircraft, model=models.Aircraft)
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
organization_ids: Optional[Set[int]] = None,
*args,
**kwargs,
):
filters = []
search = {}
if kwargs.get("call_sign"):
filters.append(models.Aircraft.call_sign == kwargs['call_sign'])
if kwargs.get("search"):
search['search'] = kwargs.pop("search", None)
query = super().get_query(
object_id=object_id,
only_my=False,
only_public=kwargs.get("only_public", False),
order_by=order_by,
filters=filters,
**search
)
if organization_ids:
query = (
query.filter(
or_(
models.Aircraft.created_by_id == user_id,
and_(
models.Aircraft.organization_id.in_(organization_ids),
models.Aircraft.is_public.is_(True)
)
)
)
)
elif user_id:
filters.append(models.Aircraft.created_by_id == user_id)
return query
class AircraftMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(graphql_type=Aircraft, model=models.Aircraft)
async def create(self, context, data: CreateAircraftInput) -> Aircraft:
input_data = data.to_dict()
async with get_session() as db:
input_data['created_by_id'] = context.user_id
if data.organization:
input_data['organization_id'] = await handle_combobox_save(
db,
models.Organization,
input=data.organization,
user_id=context.user_id,
)
return await self._do_create(db, data=input_data)
async def update(self, id: int, user_id: int, data: EditAircraftInput) -> Aircraft:
update_data = data.to_dict()
async with get_session() as db:
if data.organization:
update_data['organization_id'] = await handle_combobox_save(
db,
models.Organization,
input=data.organization,
user_id=user_id,
)
return await self._do_update(db, id, update_data)
@@ -0,0 +1,133 @@
from typing import Optional, Type, TypeVar, Generic, List
from sqlalchemy import or_
from sqlalchemy.ext.asyncio import AsyncSession
from database import models
from database.query_builder import QueryBuilder
from database.transaction import get_session
from graphql_schema.entities.types.base import BaseGraphqlInputType
GQL_TYPE = TypeVar('GQL_TYPE')
class BaseResolver(Generic[GQL_TYPE]):
def __init__(self, graphql_type: GQL_TYPE, model: Type[models.BaseModel]):
self.graphql_type = graphql_type
self.model = model
self.query_builder = QueryBuilder(self.model)
class BaseQueryResolver(BaseResolver):
async def _get_list(self, query) -> List[GQL_TYPE]:
async with get_session() as db:
items = (await db.scalars(query)).all()
return [self.graphql_type(**m.as_dict()) for m in items]
async def _get_one(self, query) -> GQL_TYPE:
async with get_session() as db:
data = (await db.scalars(query)).one()
return self.graphql_type(**data.as_dict())
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
only_public: Optional[bool] = False,
only_my: Optional[bool] = False,
include_others_public: Optional[bool] = False,
url_slug: Optional[str] = None,
filters: Optional[list] = None,
**kwargs,
):
query = self.query_builder.get_simple_query(
created_by_id=user_id,
order_by=order_by,
only_public=only_public,
include_others_public=include_others_public,
only_my=only_my,
url_slug=url_slug
)
if object_id:
if not hasattr(self.model, "id"):
raise AssertionError(f"Model {self.model} has no ID column! Cannot query by ID!")
query = query.filter(self.model.id == object_id)
query = self._handle_search(query, kwargs.pop("search", None))
if filters:
query = query.filter(*filters)
return query
def _handle_search(self, query, search):
if not search:
return query
search_clauses = []
if hasattr(self.model, "name"):
search_clauses.append(self.model.name.contains(search))
if hasattr(self.model, "description"):
search_clauses.append(self.model.description.contains(search))
if search_clauses:
query = query.filter(or_(*search_clauses))
return query
async def get_list(self, user_id: Optional[int] = None, **kwargs) -> List[GQL_TYPE]:
query = self.get_query(user_id=user_id, **kwargs)
return await self._get_list(query)
async def get_one(self, user_id: Optional[int] = None, **kwargs) -> GQL_TYPE:
query = self.get_query(user_id=user_id, **kwargs)
return await self._get_one(query)
class BaseMutationResolver(BaseResolver):
async def _get_one(self, db: AsyncSession, id: int, created_by_id: int) -> models.BaseModel:
query = self.query_builder.get_simple_query(created_by_id=created_by_id).filter(self.model.id == id)
return (await db.scalars(query)).one()
async def _do_create(self, db: AsyncSession, data: dict) -> GQL_TYPE:
model = await self.model.create(db, data=data)
return self.graphql_type(**model.as_dict())
async def _do_update(self, db: AsyncSession, obj: models.BaseModel | dict | int, data: dict) -> GQL_TYPE:
update_where = {}
if isinstance(obj, models.BaseModel):
update_where['obj'] = obj
elif isinstance(obj, dict):
update_where['id'] = obj['id']
else:
update_where['id'] = obj
model = await self.model.update(db, data=data, **update_where)
return self.graphql_type(**model.as_dict())
async def create(self, context, data: BaseGraphqlInputType) -> GQL_TYPE:
input_data = data.to_dict()
if hasattr(self.model, "created_by_id"):
input_data['created_by_id'] = context.user_id
async with get_session() as db:
return await self._do_create(db, input_data)
async def update(self, id: int, data: BaseGraphqlInputType, user_id: int) -> GQL_TYPE:
async with get_session() as db:
item = await self._get_one(db, id, user_id)
return await self._do_update(db, item, data.to_dict())
async def delete(self, user_id: int, id: int, **kwargs) -> GQL_TYPE:
async with get_session() as db:
model = await self._get_one(db, id, user_id)
if hasattr(self.model, "deleted"):
model = await self.model.update(db, obj=model, data=dict(deleted=True))
else:
await db.delete(model)
return self.graphql_type(**model.as_dict())
@@ -0,0 +1,49 @@
from typing import Optional
from sqlalchemy import and_
from database import models
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Copilot
class CopilotQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(Copilot, models.Copilot)
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
only_public: Optional[bool] = False,
**kwargs
):
pilot_username = kwargs.pop("pilot_username", None)
upload_token = kwargs.pop("upload_token", None)
upload_flight_slug = kwargs.pop("upload_flight_slug", None)
query = super().get_query(
user_id=user_id, object_id=object_id, order_by=order_by,
only_public=only_public,
only_my=bool(user_id) and not only_public,
**kwargs
)
if pilot_username:
query = (
query.join(models.Copilot.created_by)
.filter(models.User.public_username == pilot_username)
)
if upload_token and upload_flight_slug:
query = (
query.join(models.Copilot.flights)
.filter(and_(
models.Flight.url_slug == upload_flight_slug,
models.Flight.created_by_id == models.User.id
))
.filter(models.flight_has_copilot.c.token == upload_token)
)
return query
@@ -0,0 +1,35 @@
from typing import Optional
from database import models
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Event
class EventQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(graphql_type=Event, model=models.Event)
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
only_public: Optional[bool] = True,
*args,
**kwargs,
):
query = super().get_query(
user_id, object_id,
order_by=order_by,
url_slug=kwargs.get("url_slug"),
only_public=only_public,
only_my=True
)
if kwargs.get('username'):
query = (
query
.join(models.Event.created_by)
.filter(models.User.public_username == kwargs['username'])
)
return query
@@ -0,0 +1,297 @@
import asyncio
from typing import List, Optional
from sqlalchemy import delete, insert, select, func, text
from sqlalchemy.ext.asyncio import AsyncSession
from strawberry.file_uploads import Upload
from background_jobs.elevation import add_terrain_elevation_to_flight
from background_jobs.weather import download_weather
from database import models
from database.models import flight_has_copilot
from database.transaction import get_session
from external.gpx_parser import GPXParser
from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.types.mutation_input import (
EditFlightInput, TrackItemInput, ComboboxInput, CreateFlightInput
)
from graphql_schema.entities.types.types import Flight
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import delete_file
from utils.str_utils import random_str
from utils.upload import handle_file_upload
class FlightQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(graphql_type=Flight, model=models.Flight)
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
url_slug: Optional[str] = None,
only_public: Optional[bool] = False,
*args,
**kwargs
):
filters = {}
if object_id:
filters['object_id'] = object_id
if url_slug:
filters['url_slug'] = url_slug
query = super().get_query(
user_id,
**filters,
order_by=[models.Flight.takeoff_datetime.desc()],
only_public=only_public,
only_my=not only_public
)
if kwargs.get("event_id"):
query = query.filter(models.Flight.event_id == kwargs['event_id'])
if kwargs.get("aircraft_id"):
query = query.filter(models.Flight.aircraft_id == kwargs['aircraft_id'])
if kwargs.get("copilot_id"):
query = (
query.join(models.flight_has_copilot)
.filter(models.flight_has_copilot.c.copilot_id == kwargs['copilot_id'])
)
if kwargs.get("point_of_interest_id"):
query = (
query.join(models.Flight.track)
.filter(models.FlightTrack.point_of_interest_id == kwargs["point_of_interest_id"])
)
if kwargs.get('username'):
query = (
query.join(models.Flight.created_by)
.filter(models.User.public_username == kwargs['username'])
)
return query
class FlightMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(Flight, models.Flight)
async def get_airport_id_by_gps(self, gps_lat: float, gps_lng: float) -> Optional[int]:
async with (get_session() as db):
query = (
select(models.Airport, func.coalesce(6371 * func.acos(
func.cos(func.radians(gps_lat)) *
func.cos(func.radians(models.Airport.gps_latitude)) *
func.cos(func.radians(models.Airport.gps_longitude) - func.radians(gps_lng)) +
func.sin(func.radians(gps_lat)) *
func.sin(func.radians(models.Airport.gps_latitude))
), 9999).label("distance"))
.filter(models.Airport.use_in_gpx_guess.is_(True))
.order_by("distance")
.having(text("distance < 1"))
.limit(1)
)
data = (await db.execute(query)).one_or_none()
if data:
airport, distance = data
return airport.id
return None
async def extract_data_from_gpx(self, gpx_filename: str) -> dict:
data = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}")
times, coordinates = await asyncio.gather(
data.get_times(),
data.get_coordinates()
)
takeoff_airport_id, landing_airport_id = await asyncio.gather(
self.get_airport_id_by_gps(coordinates[0]['lat'], coordinates[0]['lng']),
self.get_airport_id_by_gps(coordinates[-1]['lat'], coordinates[-1]['lng']),
)
return {
"takeoff_airport_id": takeoff_airport_id,
"landing_airport_id": landing_airport_id,
"takeoff_datetime": times[0],
"landing_datetime": times[-1],
}
async def create(self, context, input: CreateFlightInput) -> Flight:
data = input.to_dict()
user_id = context.user_id
if input.gpx_track_file:
data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, context=context)
data_from_gpx = await self.extract_data_from_gpx(data['gpx_track_filename'])
data.update(data_from_gpx)
else:
async with get_session() as db:
data.update({
"takeoff_airport_id": await handle_combobox_save(
db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code",
extra_data={"name": input.takeoff_airport.name}
),
"landing_airport_id": await handle_combobox_save(
db, models.Airport, input.landing_airport, user_id, name_column="icao_code",
extra_data={"name": input.landing_airport.name}
),
})
async with get_session() as db:
aircraft_id = await handle_aircraft_save(db, context.user_id, input.aircraft)
data.update({
"aircraft_id": aircraft_id,
"has_terrain_elevation": False,
"name": "",
"description": "",
"created_by_id": context.user_id
})
flight = await self._do_create(db, data)
context.background_tasks.add_task(
download_weather,
flight_id=flight.id, airport_id=flight.takeoff_airport_id, date_time=flight.takeoff_datetime,
type_="takeoff"
)
context.background_tasks.add_task(
download_weather,
flight_id=flight.id, airport_id=flight.landing_airport_id, date_time=flight.landing_datetime,
type_="landing"
)
return flight
async def update(self, context, id: int, input: EditFlightInput) -> Flight:
user_id = context.user_id
async with get_session() as db:
flight = await self._get_one(db, id, user_id)
flight_data = flight.as_dict()
flight_id = flight.id
data = input.to_dict()
if input.gpx_track_file is not None:
data['gpx_track_filename'] = await handle_upload_gpx(
gpx_track=input.gpx_track_file,
context=context,
original_gpx_filename=flight_data['gpx_track_filename']
)
async with get_session() as db:
if input.takeoff_airport:
takeoff_airport_id = await handle_combobox_save(
db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code",
extra_data={"name": input.takeoff_airport.name}
)
data['takeoff_airport_id'] = takeoff_airport_id
data['takeoff_datetime'] = input.takeoff_datetime or flight_data['takeoff_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=takeoff_airport_id, date_time=data['takeoff_datetime'],
type_="takeoff"
)
if input.landing_airport:
landing_airport_id = await handle_combobox_save(
db, models.Airport, input.landing_airport, user_id, name_column="icao_code",
extra_data={"name": input.landing_airport.name}
)
data['landing_airport_id'] = landing_airport_id
data['landing_datetime'] = input.landing_datetime or flight_data['landing_datetime']
context.background_tasks.add_task(
download_weather, flight_id=id, airport_id=landing_airport_id, date_time=data['landing_datetime'],
type_="landing"
)
if input.aircraft is not None:
data['aircraft_id'] = await handle_aircraft_save(db, user_id, input.aircraft)
if input.event is not None:
data['event_id'] = await handle_combobox_save(
db,
model=models.Event,
input=input.event,
extra_data={"description": "", "is_public": False},
user_id=context.user_id
)
if input.track is not None:
await handle_track_edit(db=db, flight_id=flight_id, track=input.track, user_id=user_id)
if input.copilots is not None:
await db.execute(delete(flight_has_copilot).filter_by(flight_id=flight_id))
copilots = await asyncio.gather(*[
handle_combobox_save(db, models.Copilot, copilot, user_id) for copilot in input.copilots
])
for copilot_id in copilots:
await db.execute(insert(flight_has_copilot).values(
flight_id=flight_id,
copilot_id=copilot_id,
token=random_str(64)
))
return await self._do_update(db, flight_data, data)
async def handle_upload_gpx(gpx_track: Upload, context, original_gpx_filename: Optional[str] = None):
if original_gpx_filename:
delete_file(FLIGHT_GPX_TRACK_PATH + "/" + original_gpx_filename, silent=True)
filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH)
context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=id, gpx_filename=filename)
return filename
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))
order = 0
for item in track:
poi_id = None
airport_id = None
if item.point_of_interest:
poi_id = await handle_combobox_save(
db, models.PointOfInterest, item.point_of_interest, user_id, extra_data={"description": ""}
)
if item.airport:
airport_id = await handle_combobox_save(
db, models.Airport, item.airport, user_id,
name_column="icao_code",
extra_data={"name": item.airport.name}
)
await models.FlightTrack.create(
db,
data={
"flight_id": flight_id,
"point_of_interest_id": poi_id,
"airport_id": airport_id,
"order": order,
"landing_duration": item.landing_duration if airport_id else None
}
)
order += 1
async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: ComboboxInput):
return await handle_combobox_save(
db, models.Aircraft, aircraft, user_id,
name_column="call_sign",
extra_data={
"description": "",
"model": "",
"seats": 2,
"manufacturer": "",
})
@@ -0,0 +1,32 @@
from typing import Optional
from sqlalchemy import select
from database import models
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Organization
class OrganizationQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(Organization, models.Organization)
def get_query(
self,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
**kwargs
):
query = (
select(models.Organization)
.filter(models.Organization.deleted.is_(False))
)
if not order_by:
order_by = [models.Organization.name]
query = query.order_by(*order_by)
if object_id:
query = query.where(models.Organization.id == object_id)
return query
@@ -0,0 +1,284 @@
import os
import shutil
from time import time
from typing import Optional
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import delete, insert, select
from background_jobs.elevation import add_terrain_elevation_to_photo
from background_jobs.photo import generate_thumbnail, resize_photo
from database import models
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.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput
from graphql_schema.entities.types.types import Photo
from paths import get_photo_basepath
from utils.file import delete_file
from utils.image import PhotoEditor, parse_exif_info
from utils.upload import handle_file_upload
class PhotoQueryResolver(BaseQueryResolver):
def __init__(self):
super().__init__(Photo, models.Photo)
def get_query(
self,
user_id: Optional[int] = None,
object_id: Optional[int] = None,
order_by: Optional[list] = None,
only_public: Optional[bool] = False,
*args, **kwargs
):
query = super().get_query(
user_id, object_id, order_by,
filters=[
getattr(self.model, field) == kwargs[field]
for field in ("aircraft_id", "point_of_interest_id", "flight_id")
if kwargs.get(field)
]
)
if kwargs.get("public"):
query = (
query.join(models.Flight, onclause=models.Photo.flight_id == models.Flight.id)
.filter(models.Flight.is_public.is_(True))
)
if kwargs.get("copilot_id"):
query = (
query.join(models.copilot_has_photo)
.filter(models.copilot_has_photo.c.copilot_id == kwargs['copilot_id'])
)
if kwargs.get("uploaded_by_copilot_id"):
query = query.filter(models.Photo.created_by_copilot_id == kwargs['uploaded_by_copilot_id'])
return query
class PhotoDetailInfo(BaseModel):
filename: str
original_filename: str
path: str
flight_id: int
class PhotoMutationResolver(BaseMutationResolver):
def __init__(self):
super().__init__(Photo, models.Photo)
@staticmethod
def _copy_original(path: str, filename: str):
original_filename = "_original_" + filename
if not os.path.isfile(path + "/" + original_filename):
shutil.copyfile(path + "/" + filename, path + "/" + original_filename)
return original_filename
async def _get_photo_details(self, id: int, user_id: int, copilot_token: Optional[str]):
async with get_session() as db:
if not copilot_token:
photo = await self._get_one(db, id, created_by_id=user_id)
else:
photo = await self._get_photo_info_by_copilot_token(db, photo_id=id, copilot_token=copilot_token)
flight_id = photo.flight_id
filename = photo.filename + "." + photo.filename_extension
path = get_photo_basepath(flight_id)
return PhotoDetailInfo(
flight_id=flight_id,
path=path,
filename=filename,
original_filename=self._copy_original(path, filename) # TODO: tohle nedelat v getteru!
)
@staticmethod
async def _get_photo_info_by_copilot_token(db, photo_id: int, copilot_token: Optional[str]):
if not copilot_token:
return
return (
await db.scalars(
select(models.Photo)
.join(models.Photo.flight)
.join(models.flight_has_copilot)
.filter(models.flight_has_copilot.c.token == copilot_token)
.filter(models.Photo.id == photo_id)
)
).one()
@staticmethod
async def _validate_copilot_token(flight_id: int, token: str) -> tuple[int, int]:
async with get_session() as db:
query = (
select(models.Copilot.created_by_id, models.Copilot.id)
.join(models.flight_has_copilot)
.filter(models.flight_has_copilot.c.flight_id == flight_id)
.filter(models.flight_has_copilot.c.token == token)
)
return (await db.execute(query)).fetchone()
async def upload(self, info, input: UploadPhotoInput) -> Photo:
created_by_id = info.context.user_id
copilot_id = None
if input.copilot_upload_token:
created_by_id, copilot_id = await self._validate_copilot_token(input.flight_id, input.copilot_upload_token)
path = get_photo_basepath(input.flight_id)
img_name = await handle_file_upload(input.photo, path, uid_prefix=False, overwrite=False)
img = Image.open(f"{path}/{img_name}")
filename, filename_ext = os.path.splitext(img_name)
exif_info = await parse_exif_info(path, img_name)
async with get_session() as db:
photo = await PhotoMutationResolver()._do_create(
db,
data={
"flight_id": input.flight_id,
"name": input.name,
"filename": filename,
"filename_extension": filename_ext[1:], # nechci ukladat tecku na zacatku
"cache_key": int(time()),
"width": img.width,
"height": img.height,
"description": input.description,
"exposed_at": exif_info.get("datetime_original"),
"gps_latitude": exif_info.get("gps_latitude"),
"gps_longitude": exif_info.get("gps_longitude"),
"gps_altitude": exif_info.get("gps_altitude"),
"created_by_id": created_by_id,
"created_by_copilot_id": copilot_id
},
)
info.context.background_tasks.add_task(resize_photo, path=path, filename=img_name, photo_id=photo.id)
info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=img_name)
if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"):
info.context.background_tasks.add_task(add_terrain_elevation_to_photo, photo=photo)
return photo
async def update(self, id: int, input: EditPhotoInput, user_id: int) -> Photo:
data = input.to_dict()
async with get_session() as db:
photo = await self._get_one(db, id, created_by_id=user_id)
if input.point_of_interest:
data['point_of_interest_id'] = await handle_combobox_save(
db,
models.PointOfInterest,
input.point_of_interest,
user_id,
extra_data={"description": ""}
)
if input.copilots is not None:
await db.execute(delete(models.copilot_has_photo).filter_by(photo_id=id))
for copilot in input.copilots:
await db.execute(insert(models.copilot_has_photo).values(photo_id=id, copilot_id=copilot.id))
return await self._do_update(db, obj=photo, data=data)
async def change_orientation(
self, id: int, user_id: int, direction: str, copilot_upload_token: Optional[str], info
):
photo = await self._get_photo_details(id, user_id, copilot_upload_token)
degrees_map = {
"clockwise": 90,
"counterClockwise": -90
}
# rotate original
editor = PhotoEditor(photo.path, photo.original_filename)
editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False)
editor.write_to_file(quality=100)
# rotate possibly adjusted image
editor = PhotoEditor(photo.path, photo.filename)
editor.rotate(degrees=degrees_map[direction], crop_after_rotate=False)
editor.write_to_file(quality=100)
info.context.background_tasks.add_task(generate_thumbnail, path=photo.path, filename=photo.filename)
async with get_session() as db:
return await self._do_update(db, obj={"id": id}, data={
"width": editor.img.width,
"height": editor.img.height,
"cache_key": int(time())
})
async def adjust(self, id: int, user_id: int, adjustment: AdjustmentInput, info):
photo = await self._get_photo_details(id, user_id, copilot_token=None)
editor = (
PhotoEditor(photo.path, photo.original_filename)
.adjust(
brightness=adjustment.brightness,
contrast=adjustment.contrast,
sharpness=adjustment.sharpness,
saturation=adjustment.saturation
)
)
if adjustment.rotate:
rotate_angle = adjustment.rotate
editor.rotate(rotate_angle, adjustment.crop_after_rotate)
if adjustment.crop:
editor.crop(**adjustment.crop.to_dict())
editor.write_to_file(dest_filename=photo.filename)
info.context.background_tasks.add_task(generate_thumbnail, path=photo.path, filename=photo.filename)
async with (get_session() as db):
await db.execute(delete(models.PhotoAdjustment).filter(models.PhotoAdjustment.photo_id == id))
crop_info = {
"crop_" + key: value
for key, value in adjustment.crop.to_dict().items()
} if adjustment.crop else {}
await models.PhotoAdjustment.create(db, {
"photo_id": id,
"contrast": adjustment.contrast,
"saturation": adjustment.saturation,
"brightness": adjustment.brightness,
"rotate": adjustment.rotate,
"sharpness": adjustment.sharpness,
**crop_info
})
return await self._do_update(db, obj={"id": id}, data={
"width": editor.img.width,
"height": editor.img.height,
"cache_key": int(time())
})
async def delete(self, user_id: int, id: int, **kwargs) -> Photo:
copilot_upload_token = kwargs.get("copilot_upload_token")
await self._get_photo_details(id, user_id, copilot_upload_token)
# ^^ pokud neexistuje, vyhodi vyjimku
photo = await super().delete(user_id, id)
base_path = get_photo_basepath(photo.flight_id)
files_to_delete = [
photo.filename,
f"_original_{photo.filename}",
f"{photo.filename}.{photo.filename_extension}",
f"_original_{photo.filename}.{photo.filename_extension}",
f"thumbs/{photo.filename}",
f"thumbs/{photo.filename}.{photo.filename_extension}",
f"thumbs/{photo.filename}.webp",
]
for filename in files_to_delete:
delete_file(f"{base_path}/{filename}", silent=True)
return photo
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from typing import List
class BaseGraphqlInputType:
base_ignored_fields: List[str] = ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"]
ignored_fields: List[str] = []
def to_dict(self) -> dict:
dict_data = {}
for key in self.__annotations__.keys():
value = getattr(self, key)
if value is None or key in (self.ignored_fields + self.base_ignored_fields):
continue
if isinstance(value, datetime):
value = value.astimezone()
dict_data[key] = value
return dict_data
@@ -0,0 +1,139 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
import strawberry
from strawberry.file_uploads import Upload
from database import models
from graphql_schema.entities.types.base import BaseGraphqlInputType
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_input
@strawberry.input()
class ComboboxInput:
id: Optional[int] = None
name: str
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
class CreateCopilotInput(BaseGraphqlInputType):
pass
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"], all_optional=True)
class EditCopilotInput(BaseGraphqlInputType):
pass
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'])
class CreatePointOfInterestInput(BaseGraphqlInputType):
type: Optional[ComboboxInput] = None
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'], all_optional=True)
class EditPointOfInterestInput(BaseGraphqlInputType):
type: Optional[ComboboxInput] = None
@strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"])
class CreateOrganizationInput(BaseGraphqlInputType):
pass
@strawberry_sqlalchemy_input(model=models.Organization, exclude_fields=["id"])
class EditOrganizationInput(BaseGraphqlInputType):
pass
@strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"])
class CreateEventInput(BaseGraphqlInputType):
pass
@strawberry_sqlalchemy_input(model=models.Event, exclude_fields=["id"], all_optional=True)
class EditEventInput(BaseGraphqlInputType):
pass
@strawberry.input
class UploadPhotoInput:
photo: Upload
flight_id: int
name: Optional[str] = None
description: Optional[str] = None
point_of_interest: Optional[ComboboxInput] = None
copilot_upload_token: Optional[str] = None
@strawberry.input
class EditPhotoInput:
name: Optional[str] = None
description: Optional[str] = None
point_of_interest: Optional[ComboboxInput] = None
copilots: Optional[List[ComboboxInput]] = None
aircraft_id: Optional[int] = None
def to_dict(self):
return {
key: getattr(self, key) for key in ('name', 'description', 'aircraft_id')
if getattr(self, key) is not None
}
@strawberry.input
class CropInput(BaseGraphqlInputType):
left: float
top: float
width: float
height: float
@strawberry.input
class AdjustmentInput:
rotate: Optional[float] = 0
crop_after_rotate: Optional[bool] = True,
brightness: Optional[float] = 1
contrast: Optional[float] = 1
saturation: Optional[float] = 1
sharpness: Optional[float] = 1
crop: Optional[CropInput] = None
@strawberry_sqlalchemy_input(models.Flight, exclude_fields=[
"id", "aircraft_id", "deleted", "landing_airport_id", "takeoff_airport_id",
"takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id"
], all_optional=True)
class EditFlightInput(BaseGraphqlInputType):
gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
track: Optional[List[TrackItemInput]] = None
copilots: Optional[List[ComboboxInput]] = None
aircraft: Optional[ComboboxInput] = None
landing_airport: Optional[ComboboxInput] = None
takeoff_airport: Optional[ComboboxInput] = None
event: Optional[ComboboxInput] = None
@strawberry.input()
class CreateFlightInput(BaseGraphqlInputType):
aircraft: ComboboxInput
gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru!
landing_airport: Optional[ComboboxInput] = None
takeoff_airport: Optional[ComboboxInput] = None
takeoff_datetime: Optional[datetime] = None
landing_datetime: Optional[datetime] = None
@strawberry.input()
class TrackItemInput:
point_of_interest: Optional[ComboboxInput] = None
airport: Optional[ComboboxInput] = None
landing_duration: Optional[int] = None
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename'])
class CreateAircraftInput(BaseGraphqlInputType):
organization: Optional[ComboboxInput] = None
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['photo_filename'], all_optional=True)
class EditAircraftInput(BaseGraphqlInputType):
organization: Optional[ComboboxInput] = None
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional, List
import strawberry
from database import models
from decorators.endpoints import authenticated_user_only
from external.gpx_parser import GPXParser
from graphql_schema.dataloaders.flight_duration import flight_duration_dataloader
from graphql_schema.dataloaders.multi_models import (
poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_track_dataloader,
photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader,
aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_dataloader,
flights_by_copilot_dataloader, public_flights_by_event_dataloader, public_flights_by_copilot_dataloader,
photo_copilots_dataloader, photos_aircraft_dataloader, copilots_in_photo_dataloader
)
from graphql_schema.dataloaders.single_model import (
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader,
airport_weather_info_loader, organizations_dataloader, flight_dataloader, photo_adjustment_dataloader,
photo_dataloader, user_dataloader
)
from graphql_schema.permissions import IsAuthenticated
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH
@strawberry.type
class Point:
lat: float
lng: float
@strawberry.type
class GPXTrack:
coordinates: List[Point]
speed: List[float]
altitude: List[float]
magnetic_variation: List[float]
terrain_elevation: List[float]
time: List[datetime]
max_speed: float
avg_speed: float
max_altitude: float
avg_altitude: float
@strawberry_sqlalchemy_type(models.Airport)
class Airport:
pass
@strawberry.type
class Airspace:
id: int
name: str
country: str
type: str
lower_limit: str
upper_limit: str
center: Optional[tuple[float, float]]
radius: Optional[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(
resolver=lambda root: [Point(lat=point[0], lng=point[1]) for point in root.bounds]
)
@strawberry_sqlalchemy_type(models.FlightTrack)
class FlightTrack:
point_of_interest: Optional[PointOfInterest] = strawberry.field(
resolver=lambda root: poi_dataloader.load(root.point_of_interest_id)
)
airport: Optional[Airport] = strawberry.field(resolver=lambda root: airport_dataloader.load(root.airport_id))
@strawberry_sqlalchemy_type(models.PointOfInterestType)
class PointOfInterestType:
pass
@strawberry_sqlalchemy_type(models.WeatherInfo)
class WeatherInfo:
pass
@strawberry_sqlalchemy_type(models.PhotoAdjustment)
class PhotoAdjustment:
photo: Photo = strawberry.field(resolver=lambda root: photo_dataloader.load(root.photo_id))
@strawberry_sqlalchemy_type(models.PointOfInterest)
class PointOfInterest:
type: Optional[PointOfInterestType] = strawberry.field(resolver=lambda root: poi_type_dataloader.load(root.type_id))
photos: List[Photo] = strawberry.field(resolver=lambda root: poi_photos_dataloader.load(root.id))
flights: List[Flight] = strawberry.field(resolver=lambda root: flight_by_poi_dataloader.load(root.id))
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
@strawberry_sqlalchemy_type(models.Photo)
class Photo:
url: str = strawberry.field(resolver=get_photo_url)
thumbnail_url: str = strawberry.field(resolver=get_photo_thumbnail_url)
point_of_interest: Optional[PointOfInterest] = strawberry.field(
resolver=lambda root: poi_dataloader.load(root.point_of_interest_id)
)
copilots: List[Copilot] = strawberry.field(resolver=lambda root: copilots_in_photo_dataloader.load(root.id))
flight: Flight = strawberry.field(resolver=lambda root: flight_dataloader.load(root.flight_id))
adjustment: Optional[PhotoAdjustment] = strawberry.field(
resolver=lambda root: photo_adjustment_dataloader.load(root.id)
)
@strawberry_sqlalchemy_type(models.Flight)
class Flight:
def __init__(self, **kwargs):
super().__init__()
for key, value in kwargs.items():
setattr(self, key, value)
async def load_gpx_track(root):
if not root.gpx_track_filename:
return None
try:
gpx_parser = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{root.gpx_track_filename}")
except OSError:
return None
return GPXTrack(
coordinates=[Point(**point) for point in await gpx_parser.get_coordinates()],
speed=await gpx_parser.get_speed(),
altitude=await gpx_parser.get_altitude(),
terrain_elevation=await gpx_parser.get_terrain_elevation(),
time=await gpx_parser.get_times(),
max_speed=await gpx_parser.get_max_speed(),
avg_speed=await gpx_parser.get_avg_speed(),
max_altitude=await gpx_parser.get_max_altitude(),
avg_altitude=await gpx_parser.get_avg_altitude(),
magnetic_variation=await gpx_parser.get_magnetic_variation(),
)
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
async def load_copilots(root):
return await flight_copilots_dataloader.load(root.id)
@authenticated_user_only(raise_when_unauthorized=False, return_value_unauthorized=[])
async def load_event(root):
return await event_dataloader.load(root.event_id)
pilot: User = strawberry.field(resolver=lambda root: user_dataloader.load(root.created_by_id))
copilots: Optional[List[Copilot]] = strawberry.field(resolver=load_copilots)
event: Optional[Event] = strawberry.field(resolver=load_event)
aircraft: Aircraft = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id))
takeoff_airport: Optional[Airport] = strawberry.field(
resolver=lambda root: airport_dataloader.load(root.takeoff_airport_id)
)
landing_airport: Optional[Airport] = strawberry.field(
resolver=lambda root: airport_dataloader.load(root.landing_airport_id)
)
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
track: List[FlightTrack] = strawberry.field(resolver=lambda root: flight_track_dataloader.load(root.id))
takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(
resolver=lambda root: airport_weather_info_loader.load(root.takeoff_weather_info_id)
)
landing_weather_info: Optional[WeatherInfo] = strawberry.field(
resolver=lambda root: airport_weather_info_loader.load(root.landing_weather_info_id)
)
photos: List[Photo] = strawberry.field(resolver=lambda root: photos_dataloader.load(root.id))
gpx_track: Optional[GPXTrack] = strawberry.field(resolver=load_gpx_track)
duration_min_calculated: int = strawberry.field(
resolver=lambda root: flight_duration_dataloader.load(root.id)
)
debriefing_notes: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated],
resolver=lambda root: root.debriefing_notes
)
@strawberry_sqlalchemy_type(models.Copilot)
class Copilot:
async def resolve_flights(root, info):
dataloader = public_flights_by_copilot_dataloader
if info.context.user_id:
dataloader = flights_by_copilot_dataloader
return await dataloader.load(root.id)
def resolve_copilot_token(root, info):
return getattr(root, "token")
flights: List[Flight] = strawberry.field(resolver=resolve_flights)
photos: List[Photo] = strawberry.field(resolver=lambda root: photo_copilots_dataloader.load(root.id))
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
token: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated],
resolver=resolve_copilot_token
)
@strawberry_sqlalchemy_type(models.Aircraft)
class Aircraft:
flights: List[Flight] = strawberry.field(resolver=lambda root: flights_by_aircraft_dataloader.load(root.id))
organization: Optional[Organization] = strawberry.field(
resolver=lambda root: organizations_dataloader.load(root.organization_id)
)
photos: List[Photo] = strawberry.field(resolver=lambda root: photos_aircraft_dataloader.load(root.id))
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
@strawberry_sqlalchemy_type(models.Organization)
class Organization:
users: List[User] = strawberry.field(resolver=lambda root: users_in_organization_dataloader.load(root.id))
aircrafts: List[Aircraft] = strawberry.field(
resolver=lambda root: aircrafts_from_organization_dataloader.load(root.id)
)
@strawberry_sqlalchemy_type(models.User, exclude_fields=['password_hashed'])
class User:
avatar_image_url: Optional[str] = strawberry.field(resolver=lambda root: get_avatar_url(root))
title_image_url: str = strawberry.field(resolver=lambda root: get_title_image_url(root))
organizations: List[Organization] = strawberry.field(
resolver=lambda root: user_organizations_dataloader.load(root.id)
)
@strawberry_sqlalchemy_type(models.Event)
class Event:
async def load_flights(root, info, only_public: Optional[bool] = False):
is_user_logged_in = bool(info.context.user_id)
if not is_user_logged_in or only_public:
dataloader = public_flights_by_event_dataloader
else:
dataloader = flights_by_event_dataloader
# dataloader = flights_by_event_dataloader if is_user_logged_in else public_flights_by_event_dataloader
return await dataloader.load(root.id)
flights: List[Flight] = strawberry.field(resolver=load_flights)
+25 -31
View File
@@ -1,35 +1,17 @@
from functools import wraps
from typing import Optional from typing import Optional
import strawberry import strawberry
from graphql import GraphQLError from graphql import GraphQLError
from passlib.hash import bcrypt from passlib.hash import bcrypt
from sqlalchemy import select from sqlalchemy import select
from strawberry.file_uploads import Upload from strawberry.file_uploads import Upload
from config import API_URL from background_jobs.photo import resize_photo
from database import models from database import models
from database.models import User
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from database.transaction import get_session
from upload_utils import handle_file_upload, delete_file, get_public_url, resize_image from graphql_schema.entities.types.types import User
from utils.file import delete_file
from utils.upload import handle_file_upload
@strawberry_sqlalchemy_type(User, exclude_fields=['password_hashed'])
class User:
async def load_avatar_image_url(root):
if not root.avatar_image_filename:
return None
return get_public_url(f"profile/{root.id}/{root.avatar_image_filename}")
async def load_title_image_url(root):
if not root.title_image_filename:
return f"{API_URL}/static/default-title-image.jpg"
return get_public_url(f"profile/{root.id}/{root.title_image_filename}")
avatar_image_url: Optional[str] = strawberry.field(resolver=load_avatar_image_url)
title_image_url: str = strawberry.field(resolver=load_title_image_url)
@strawberry.type @strawberry.type
@@ -40,16 +22,23 @@ class UserQueries:
if len(username) == 0: if len(username) == 0:
raise GraphQLError("Username not set!") raise GraphQLError("Username not set!")
return (await info.context.db.scalars(select(models.User).filter_by(public_username=username))).one() async with get_session() as db:
user_model = (await db.scalars(select(models.User).filter_by(public_username=username))).one()
user = User(**user_model.as_dict())
return user
@strawberry.field() @strawberry.field()
@authenticated_user_only() @authenticated_user_only()
@error_logging @error_logging
async def logged_user(root, info) -> User: async def logged_user(root, info) -> User:
return (await info.context.db.scalars( async with get_session() as db:
user_model = (await db.scalars(
select(models.User).filter_by(id=info.context.user_id) select(models.User).filter_by(id=info.context.user_id)
)).one() )).one()
return User(**user_model.as_dict())
@strawberry.type @strawberry.type
class EditUserMutation: class EditUserMutation:
@@ -66,19 +55,24 @@ class EditUserMutation:
@strawberry.mutation @strawberry.mutation
@authenticated_user_only() @authenticated_user_only()
async def edit_logged_user(root, info, input: EditUserInput) -> User: async def edit_logged_user(root, info, input: EditUserInput) -> User:
user = (await info.context.db.scalars( async with get_session() as db:
user = (await db.scalars(
select(models.User).filter_by(id=info.context.user_id) select(models.User).filter_by(id=info.context.user_id)
)).one() )).one()
user_image_path = f"/app/uploads/profile/{user.id}" user_image_path = f"/app/uploads/profile/{user.id}"
data = input.to_dict() data = {
key: getattr(input, key)
for key in ("name", "description", "public_username")
if getattr(input, key) is not None
}
if input.avatar_image: if input.avatar_image:
if user.avatar_image_filename: if user.avatar_image_filename:
delete_file(f"{user_image_path}/{user.avatar_image_filename}", silent=True) delete_file(f"{user_image_path}/{user.avatar_image_filename}", silent=True)
data['avatar_image_filename'] = await handle_file_upload(input.avatar_image, user_image_path) data['avatar_image_filename'] = await handle_file_upload(input.avatar_image, user_image_path)
info.context.background_tasks.add_task( info.context.background_tasks.add_task(
resize_image, path=user_image_path, filename=data['avatar_image_filename'], new_width=400 resize_photo, path=user_image_path, filename=data['avatar_image_filename'], new_width=400
) )
if input.title_image: if input.title_image:
@@ -86,9 +80,8 @@ class EditUserMutation:
delete_file(f"{user_image_path}/{user.title_image_filename}", silent=True) delete_file(f"{user_image_path}/{user.title_image_filename}", silent=True)
data['title_image_filename'] = await handle_file_upload(input.title_image, user_image_path) data['title_image_filename'] = await handle_file_upload(input.title_image, user_image_path)
info.context.background_tasks.add_task( info.context.background_tasks.add_task(
resize_image, path=user_image_path, filename=data['title_image_filename'], new_width=800 resize_photo, path=user_image_path, filename=data['title_image_filename'], new_width=800
) )
if input.old_password and input.new_password: if input.old_password and input.new_password:
@@ -97,4 +90,5 @@ class EditUserMutation:
data['password_hashed'] = bcrypt.hash(input.new_password) data['password_hashed'] = bcrypt.hash(input.new_password)
return await models.User.update(info.context.db, obj=user, data=data) user_model = await models.User.update(db, obj=user, data=data)
return User(**user_model.as_dict())
+15 -18
View File
@@ -1,24 +1,21 @@
from strawberry.tools import merge_types from strawberry.tools import merge_types
from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation from graphql_schema.entities.aircraft import AircraftMutation
from graphql_schema.entities.copilot import CreateCopilotMutation, EditCopilotMutation from graphql_schema.entities.copilot import CopilotMutation
from graphql_schema.entities.flight import CreateFlightMutation, EditFlightMutation, DeleteFlightMutation from graphql_schema.entities.event import EventMutation
from graphql_schema.entities.photo import UploadPhotoMutation, DeletePhotoMutation, EditPhotoMutation from graphql_schema.entities.flight import FlightMutation
from graphql_schema.entities.poi import CreatePointOfInterestMutation, EditPointOfInterestMutation from graphql_schema.entities.organization import OrganizationUserMutation, OrganizationMutation
from graphql_schema.entities.photo import PhotoMutation
from graphql_schema.entities.poi import PointOfInterestMutation
from graphql_schema.entities.user import EditUserMutation from graphql_schema.entities.user import EditUserMutation
Mutation = merge_types("Mutation", ( Mutation = merge_types("Mutation", (
CreateAircraftMutation, AircraftMutation,
EditAircraftMutation, FlightMutation,
DeleteAircraftMutation, PhotoMutation,
EditFlightMutation, PointOfInterestMutation,
DeleteFlightMutation, CopilotMutation,
CreateFlightMutation,
UploadPhotoMutation,
EditPhotoMutation,
DeletePhotoMutation,
CreatePointOfInterestMutation,
EditPointOfInterestMutation,
CreateCopilotMutation,
EditCopilotMutation,
EditUserMutation, EditUserMutation,
EventMutation,
OrganizationMutation,
OrganizationUserMutation,
)) ))
+13
View File
@@ -0,0 +1,13 @@
from typing import Any
from graphql import GraphQLError
from strawberry import BasePermission
from strawberry.types import Info
class IsAuthenticated(BasePermission):
message = "User is not authenticated"
error_class = GraphQLError
error_extensions = {"code": "UNAUTHORIZED"}
def has_permission(self, source: Any, info: Info, **kwargs) -> bool:
return bool(info.context.user_id)
+8
View File
@@ -1,8 +1,12 @@
from strawberry.tools import merge_types from strawberry.tools import merge_types
from .entities.aircraft import AircraftQueries from .entities.aircraft import AircraftQueries
from .entities.airport import AirportQueries from .entities.airport import AirportQueries
from .entities.airspace import AirspaceQueries
from .entities.copilot import CopilotQueries from .entities.copilot import CopilotQueries
from .entities.event import EventQueries
from .entities.flight import FlightQueries from .entities.flight import FlightQueries
from .entities.organization import OrganizationQueries
from .entities.photo import PhotoQueries
from .entities.poi import PointOfInterestQueries from .entities.poi import PointOfInterestQueries
from .entities.poi_type import PointOfInterestTypeQueries from .entities.poi_type import PointOfInterestTypeQueries
from .entities.user import UserQueries from .entities.user import UserQueries
@@ -13,9 +17,13 @@ from .entities.user import UserQueries
Query = merge_types('Query', ( Query = merge_types('Query', (
AircraftQueries, AircraftQueries,
AirportQueries, AirportQueries,
AirspaceQueries,
FlightQueries, FlightQueries,
CopilotQueries, CopilotQueries,
UserQueries, UserQueries,
PhotoQueries,
PointOfInterestQueries, PointOfInterestQueries,
PointOfInterestTypeQueries, PointOfInterestTypeQueries,
EventQueries,
OrganizationQueries,
)) ))
+3 -2
View File
@@ -1,8 +1,9 @@
import dataclasses import dataclasses
from typing import Set
import strawberry import strawberry
from fastapi_jwt import JwtAuthorizationCredentials from fastapi_jwt import JwtAuthorizationCredentials
from fastapi_jwt.jwt import JwtAccessBearerCookie from fastapi_jwt.jwt import JwtAccessBearerCookie
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.background import BackgroundTasks from starlette.background import BackgroundTasks
from strawberry.extensions import SchemaExtension from strawberry.extensions import SchemaExtension
from strawberry.fastapi import BaseContext from strawberry.fastapi import BaseContext
@@ -30,8 +31,8 @@ class LoggingExtension(SchemaExtension):
@dataclasses.dataclass @dataclasses.dataclass
class GraphQLContext(BaseContext): class GraphQLContext(BaseContext):
db: AsyncSession
user_id: int user_id: int
organization_ids: Set[int]
jwt_auth_credentials: JwtAuthorizationCredentials jwt_auth_credentials: JwtAuthorizationCredentials
jwt: JwtAccessBearerCookie jwt: JwtAccessBearerCookie
background_tasks: BackgroundTasks background_tasks: BackgroundTasks
@@ -1,10 +1,11 @@
import typing import typing
from datetime import datetime
from typing import List, Optional from typing import List, Optional
import strawberry import strawberry
import sqlalchemy import sqlalchemy
from sqlalchemy import Column from sqlalchemy import Column
from logger import log
from database.models import BaseModel from database.models import BaseModel
from graphql_schema.entities.types.base import BaseGraphqlInputType
def get_columns_from_model(model: BaseModel, exclude_fields: List[str]) -> List[typing.Tuple[str, Column]]: def get_columns_from_model(model: BaseModel, exclude_fields: List[str]) -> List[typing.Tuple[str, Column]]:
@@ -22,7 +23,7 @@ def get_annotations_for_scalars(model: BaseModel, exclude_fields=None, force_opt
type_ = typing.Optional[column.type.python_type] if is_optional else column.type.python_type type_ = typing.Optional[column.type.python_type] if is_optional else column.type.python_type
annotations_[name] = type_ annotations_[name] = type_
except NotImplementedError as e: except NotImplementedError as e:
print(f"Neimplementovano: {e}, {name=}") log.warning(f"Cannot annotate {name} in {model} for GQL type. Exception: {e}")
return annotations_ return annotations_
@@ -31,8 +32,8 @@ def strawberry_sqlalchemy_type(model, exclude_fields: Optional[typing.Union[List
if exclude_fields is None: if exclude_fields is None:
exclude_fields = [] exclude_fields = []
def from_sqlalchemy_model(model: BaseModel): def from_sqlalchemy_model(cls, model: BaseModel):
return model return cls(model)
def wrapper(cls): def wrapper(cls):
cls.__annotations__.update(get_annotations_for_scalars(model, exclude_fields=exclude_fields + ["deleted"])) cls.__annotations__.update(get_annotations_for_scalars(model, exclude_fields=exclude_fields + ["deleted"]))
@@ -45,24 +46,12 @@ def strawberry_sqlalchemy_type(model, exclude_fields: Optional[typing.Union[List
def strawberry_sqlalchemy_input( def strawberry_sqlalchemy_input(
model, model,
exclude_fields: Optional[typing.Union[List, typing.Tuple]] = None, exclude_fields: Optional[typing.Union[List, typing.Tuple]] = None,
all_optional: bool = False) -> typing.Callable[[...], strawberry.object_type]: all_optional: bool = False
) -> typing.Callable[[...], strawberry.object_type]:
if exclude_fields is None: if exclude_fields is None:
exclude_fields = [] exclude_fields = []
ignored_fields = exclude_fields + ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] ignored_fields = exclude_fields + BaseGraphqlInputType.base_ignored_fields
def to_dict(self):
dict_data = {}
for key, _ in get_columns_from_model(model, ignored_fields):
value = getattr(self, key)
if value is None:
continue
if isinstance(value, datetime):
value = value.astimezone()
dict_data[key] = value
return dict_data
def wrapper(cls): def wrapper(cls):
annotations = get_annotations_for_scalars( annotations = get_annotations_for_scalars(
@@ -72,7 +61,6 @@ def strawberry_sqlalchemy_input(
) )
cls.__annotations__.update(annotations) cls.__annotations__.update(annotations)
cls.to_dict = to_dict
for col, col_type in annotations.items(): for col, col_type in annotations.items():
try: try:

Some files were not shown because too many files have changed in this diff Show More