Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10b7163f23 | ||
|
|
ffb8d07ebe | ||
|
|
fd0aef83b1 | ||
|
|
66d9877552 | ||
|
|
6a7d51efc5 | ||
|
|
ed35de96c9 | ||
|
|
7c81069e96 | ||
|
|
400ad8b75a | ||
|
|
c0ae73440d | ||
|
|
1f8b338297 | ||
|
|
ff32c36a94 | ||
|
|
e28a6fce57 | ||
|
|
51316c492b | ||
|
|
c26eb97288 | ||
|
|
03c9f2d189 | ||
|
|
77b9552a9d | ||
|
|
cc95197682 | ||
|
|
57aad05359 | ||
|
|
dfb96e768e | ||
|
|
0e890dc201 | ||
|
|
3c757df7f1 | ||
|
|
dabed327dd | ||
|
|
3b1fd0351a | ||
|
|
3edc1c853f | ||
|
|
f4d709d621 | ||
|
|
f535d2866c | ||
|
|
89f5b8bb0c | ||
|
|
3167ea2f8a | ||
|
|
ecb37fb961 | ||
|
|
108a731608 | ||
|
|
19c1ff1846 | ||
|
|
bd5b84c9e7 | ||
|
|
e3ab6ba7f0 | ||
|
|
e8aa678555 | ||
|
|
f2e2c1a1ab | ||
|
|
ebf2995d51 | ||
|
|
52ddf6502a | ||
|
|
0de73a5ec2 | ||
|
|
3223d2ac00 | ||
|
|
322e61bce3 | ||
|
|
bc258efa3a | ||
|
|
7f7f79dad6 | ||
|
|
ce2c023eaf | ||
|
|
86fe3949f6 | ||
|
|
b355d0b439 | ||
|
|
2422f26f10 | ||
|
|
3f03021f5c | ||
|
|
dc49cd347c | ||
|
|
d820b92964 | ||
|
|
22a28ae70e | ||
|
|
2c53a8ffa3 | ||
|
|
f50c9660c7 | ||
|
|
037c7daef6 | ||
|
|
1760dba99d | ||
|
|
64cb544e2d |
@@ -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
|
||||
@@ -7,3 +7,8 @@ MYSQL_DATABASE: "poletani_cz"
|
||||
MYSQL_USER: "mysql_user"
|
||||
MYSQL_PASSWORD: "****"
|
||||
APP_ENV: "production"
|
||||
|
||||
SMTP_HOST: ""
|
||||
SMTP_PORT: ""
|
||||
SMTP_USERNAME: ""
|
||||
SMTP_PASSWORD: ""
|
||||
@@ -8,3 +8,5 @@ __pycache__/
|
||||
# app specific files and dirs
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
src/emails/templates/html/*
|
||||
.env
|
||||
|
||||
+12
-1
@@ -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 && \
|
||||
apk add --no-cache tzdata mariadb-client mariadb-dev && \
|
||||
pip install --upgrade pip
|
||||
|
||||
# protoze xfs uzivatel ma id=33
|
||||
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
|
||||
|
||||
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)
|
||||
ENV INSTALL_PACKAGES build-base linux-headers
|
||||
|
||||
+201
@@ -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.
|
||||
@@ -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 ###
|
||||
+50
@@ -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 ###
|
||||
+50
@@ -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
@@ -2,8 +2,16 @@ import csv
|
||||
|
||||
|
||||
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 = h_m.strip("0")
|
||||
# h = int(h_m[0:2])
|
||||
@@ -16,15 +24,20 @@ def deg_to_dec(val: str):
|
||||
# print("--konec-----------------------------------")
|
||||
# return h + (m / 60.0) + (int(s) / 3600.0)
|
||||
|
||||
return len(val or "")
|
||||
# return val
|
||||
|
||||
# return len(val or "")
|
||||
|
||||
|
||||
# ZDAKOV: @49.504378,14.1808905
|
||||
|
||||
# 4930.250N / 100 -> cele cislo stupne, desetinne prevest do sedesatkove soustavy
|
||||
|
||||
with open("./poi.csv") as f:
|
||||
reader = csv.DictReader(f)
|
||||
|
||||
for row in reader:
|
||||
# if row['name'] != 'ZDAKOV':
|
||||
# continue
|
||||
if row['name'] != 'ZDAKOV':
|
||||
continue
|
||||
|
||||
print(row['name'], row['lat'], row['lon'], deg_to_dec(row['lat']), deg_to_dec(row['lon']))
|
||||
@@ -0,0 +1,4 @@
|
||||
pytest
|
||||
mypy
|
||||
flake8
|
||||
pytest-asyncio
|
||||
@@ -8,5 +8,7 @@ services:
|
||||
restart: "always"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
SENTRY_DSN: "https://184d1d6bd39f4fbb94804ae84e9afdd5@o472821.ingest.sentry.io/5506983"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
|
||||
+14
-4
@@ -5,10 +5,10 @@ services:
|
||||
- 3306:3306
|
||||
environment:
|
||||
- MYSQL_USER=root
|
||||
- MYSQL_ROOT_PASSWORD=root
|
||||
- MYSQL_DATABASE=ull_tracker
|
||||
- MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true
|
||||
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
|
||||
api:
|
||||
build:
|
||||
@@ -17,19 +17,29 @@ services:
|
||||
- 8000:8000
|
||||
volumes:
|
||||
- .:/app
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
TZ: "Europe/Prague"
|
||||
API_URL: "http://localhost:8000"
|
||||
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:
|
||||
build:
|
||||
context: ../crawlers
|
||||
environment:
|
||||
- MYSQL_USER=root
|
||||
- MYSQL_PASSWORD=
|
||||
- MYSQL_PASSWORD=root
|
||||
- MYSQL_DATABASE=ull_tracker
|
||||
- MYSQL_HOST=db
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
+14
-13
@@ -1,15 +1,16 @@
|
||||
fastapi==0.100.0
|
||||
fastapi-jwt==0.1.12
|
||||
strawberry-graphql[fastapi]==0.194.4
|
||||
uvicorn==0.22.0
|
||||
sqlalchemy[asyncio] >= 2.0.9
|
||||
fastapi==0.110.0
|
||||
fastapi-jwt==0.2.0
|
||||
fastapi-mail==1.4.1
|
||||
strawberry-graphql[fastapi]==0.217.1
|
||||
uvicorn==0.26.0
|
||||
sqlalchemy[asyncio] == 2.0.25
|
||||
aiomysql==0.2.0
|
||||
alembic==1.11.1
|
||||
alembic==1.13.1
|
||||
passlib==1.7.4
|
||||
pydantic==1.10.11 # vysla uz 2.0, ale nejak mi to nefunguje
|
||||
|
||||
pillow
|
||||
exif
|
||||
aiocache
|
||||
aiohttp
|
||||
lxml
|
||||
pydantic==2.5.3
|
||||
sentry-sdk[fastapi]
|
||||
pillow==10.2.0
|
||||
exif==1.6.0
|
||||
aiocache==0.12.2
|
||||
aiohttp==3.9.3
|
||||
lxml==5.1.0
|
||||
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -2,9 +2,19 @@ import os
|
||||
|
||||
APP_DEBUG = True
|
||||
GRAPHIQL = True
|
||||
|
||||
REFRESH_TOKEN_VALIDITY_DAYS = 30
|
||||
|
||||
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()
|
||||
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:
|
||||
raise ValueError("Missing APP_SECRET_KEY!")
|
||||
|
||||
+151
-49
@@ -1,29 +1,30 @@
|
||||
from __future__ import annotations
|
||||
import datetime
|
||||
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.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class BaseModel:
|
||||
excluded_columns_in_dict = tuple()
|
||||
|
||||
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
|
||||
}
|
||||
excluded_columns_in_dict = ("deleted",)
|
||||
|
||||
@classmethod
|
||||
async def get_one(cls, db_session: AsyncSession, id: int):
|
||||
return (await db_session.scalars(select(cls).filter_by(id=id))).one()
|
||||
def _get_column_names(cls):
|
||||
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
|
||||
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)
|
||||
await db_session.flush()
|
||||
|
||||
@@ -35,9 +36,9 @@ class BaseModel:
|
||||
raise ValueError("Provide either obj or id!")
|
||||
|
||||
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():
|
||||
if getattr(obj, key) != value:
|
||||
if key in cls._get_column_names() and getattr(obj, key) != value:
|
||||
setattr(obj, key, value)
|
||||
|
||||
return obj
|
||||
@@ -55,8 +56,30 @@ flight_has_copilot = Table(
|
||||
BaseModel.metadata,
|
||||
Column("flight_id", ForeignKey("flight.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):
|
||||
__tablename__ = "airport"
|
||||
@@ -64,15 +87,17 @@ class Airport(BaseModel):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
icao_code: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
gps_longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
gps_longitude: Mapped[float] = mapped_column(Float, 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')
|
||||
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')
|
||||
|
||||
metars: Mapped['Metar'] = relationship(back_populates="airport")
|
||||
metars: Mapped['Metar'] = relationship()
|
||||
created_by: Mapped['User'] = relationship()
|
||||
|
||||
|
||||
@@ -96,7 +121,9 @@ class PointOfInterest(BaseModel):
|
||||
|
||||
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)
|
||||
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_longitude: Mapped[float] = mapped_column(Float, 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())
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
|
||||
|
||||
photos: Mapped[List[Photo]] = relationship()
|
||||
type: Mapped[PointOfInterestType] = relationship()
|
||||
created_by: Mapped['User'] = relationship()
|
||||
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
|
||||
|
||||
|
||||
class Photo(BaseModel):
|
||||
__tablename__ = "photo"
|
||||
|
||||
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)
|
||||
is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0")
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
filename_extension: Mapped[str] = mapped_column(String(4), 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)
|
||||
gps_latitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
gps_longitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
gps_altitude: Mapped[float] = mapped_column(Float, nullable=True)
|
||||
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)
|
||||
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_copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
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()
|
||||
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):
|
||||
@@ -137,7 +193,8 @@ class Aircraft(BaseModel):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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="")
|
||||
model: Mapped[str] = mapped_column(String(30), nullable=False, server_default="")
|
||||
seats: Mapped[str] = mapped_column(Integer, nullable=False)
|
||||
@@ -150,22 +207,22 @@ class Aircraft(BaseModel):
|
||||
organization: Mapped['Organization'] = relationship()
|
||||
flights: Mapped[Set['Flight']] = relationship()
|
||||
created_by: Mapped['User'] = relationship()
|
||||
notes: Mapped['AircraftNotes'] = relationship()
|
||||
# notes: Mapped['AircraftNotes'] = relationship()
|
||||
|
||||
|
||||
class AircraftNotes(BaseModel):
|
||||
__tablename__ = "aircraft_notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
created_by: Mapped['User'] = relationship()
|
||||
aircraft: Mapped['Aircraft'] = relationship(back_populates="notes")
|
||||
# class AircraftNotes(BaseModel):
|
||||
# __tablename__ = "aircraft_notes"
|
||||
#
|
||||
# id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False)
|
||||
# name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
# description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# is_public: Mapped[bool] = mapped_column(Boolean, server_default='0')
|
||||
# created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
# created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
#
|
||||
# created_by: Mapped['User'] = relationship()
|
||||
# aircraft: Mapped['Aircraft'] = relationship()
|
||||
|
||||
|
||||
class Organization(BaseModel):
|
||||
@@ -177,7 +234,7 @@ class Organization(BaseModel):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
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()
|
||||
|
||||
|
||||
@@ -186,11 +243,14 @@ class FlightTrack(BaseModel):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False)
|
||||
point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=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)
|
||||
|
||||
flight: Mapped['Flight'] = relationship()
|
||||
point_of_interest: Mapped['PointOfInterest'] = relationship()
|
||||
airport: Mapped['Airport'] = relationship()
|
||||
|
||||
|
||||
class WeatherInfo(BaseModel):
|
||||
@@ -208,19 +268,43 @@ class WeatherInfo(BaseModel):
|
||||
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):
|
||||
__tablename__ = "flight"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False)
|
||||
landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False, server_default="")
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
|
||||
debriefing_notes: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
|
||||
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)
|
||||
landing_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
duration_total: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
duration_pic: Mapped[int] = mapped_column(Integer, nullable=True)
|
||||
gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True)
|
||||
has_terrain_elevation: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey('aircraft.id'))
|
||||
takeoff_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
|
||||
landing_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True)
|
||||
@@ -235,10 +319,12 @@ class Flight(BaseModel):
|
||||
weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[landing_weather_info_id])
|
||||
weather_info_takeoff: Mapped[WeatherInfo] = relationship(foreign_keys=[takeoff_weather_info_id])
|
||||
track: Mapped['FlightTrack'] = relationship()
|
||||
event: Mapped['Event'] = relationship()
|
||||
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])
|
||||
created_by: Mapped['User'] = relationship()
|
||||
title_photo: Mapped['Photo'] = relationship(foreign_keys=[title_photo_id])
|
||||
|
||||
|
||||
class Copilot(BaseModel):
|
||||
@@ -246,12 +332,17 @@ class Copilot(BaseModel):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
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_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0')
|
||||
|
||||
flights: Mapped[Set['Flight']] = relationship(secondary=flight_has_copilot)
|
||||
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):
|
||||
@@ -263,7 +354,7 @@ class Metar(BaseModel):
|
||||
issued_at: Mapped[datetime] = mapped_column(DateTime)
|
||||
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):
|
||||
@@ -276,7 +367,7 @@ class License(BaseModel):
|
||||
created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
user: Mapped['User'] = relationship(back_populates="licences")
|
||||
user: Mapped['User'] = relationship()
|
||||
created_by: Mapped['User'] = relationship()
|
||||
|
||||
|
||||
@@ -287,7 +378,7 @@ class User(BaseModel):
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(128), nullable=False, unique=True)
|
||||
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)
|
||||
avatar_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()
|
||||
flights: Mapped[Set['Flight']] = relationship()
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -3,8 +3,22 @@ from fastapi import HTTPException
|
||||
from starlette.status import HTTP_401_UNAUTHORIZED
|
||||
|
||||
|
||||
def public_endpoint(func):
|
||||
pass
|
||||
def raise_unauthorized():
|
||||
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):
|
||||
@@ -14,7 +28,7 @@ def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_u
|
||||
if 'info' in kwargs:
|
||||
if not kwargs['info'].context.user_id:
|
||||
if raise_when_unauthorized:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized")
|
||||
raise_unauthorized()
|
||||
else:
|
||||
return return_value_unauthorized
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException
|
||||
from graphql import GraphQLError
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
|
||||
@@ -9,6 +10,9 @@ def error_logging(func):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,12 +1,6 @@
|
||||
from fastapi_jwt.jwt import JwtAccess, JwtRefresh
|
||||
|
||||
|
||||
class BaseEndpoint(object):
|
||||
def __init__(self, db):
|
||||
self.db = db
|
||||
super().__init__()
|
||||
|
||||
|
||||
class AuthEndpoint:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.access_security: JwtAccess = kwargs.pop("access_token")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -4,7 +4,8 @@ from passlib.hash import bcrypt
|
||||
from sqlalchemy import select
|
||||
from starlette.responses import Response
|
||||
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
|
||||
|
||||
|
||||
@@ -13,18 +14,22 @@ class LoginInput(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class LoginEndpoint(AuthEndpoint, BaseEndpoint):
|
||||
class LoginEndpoint(AuthEndpoint):
|
||||
async def on_post(self, user_data: LoginInput, resp: Response) -> dict:
|
||||
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:
|
||||
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")
|
||||
|
||||
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)
|
||||
refresh_token = self.refresh_security.create_refresh_token(subject=subject)
|
||||
|
||||
@@ -35,9 +40,8 @@ class LoginEndpoint(AuthEndpoint, BaseEndpoint):
|
||||
)
|
||||
|
||||
return {
|
||||
"user": logged_user.as_dict(),
|
||||
"access_token": access_token,
|
||||
"access_token_validity": self.access_security.access_expires_delta.total_seconds(),
|
||||
"user": user,
|
||||
"access_token": access_token
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -1,11 +1,13 @@
|
||||
import re
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, BackgroundTasks
|
||||
from sqlalchemy import select
|
||||
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 endpoints.base import BaseEndpoint
|
||||
from database.transaction import get_session
|
||||
from passlib.hash import bcrypt
|
||||
from emails.emails import WelcomeEmail
|
||||
|
||||
|
||||
class RegistrationInput(BaseModel):
|
||||
@@ -13,7 +15,7 @@ class RegistrationInput(BaseModel):
|
||||
name: Optional[str]
|
||||
password: str
|
||||
|
||||
@root_validator()
|
||||
@model_validator(mode="before")
|
||||
def validate_email(cls, values):
|
||||
email = values.get("email") or ""
|
||||
|
||||
@@ -23,22 +25,23 @@ class RegistrationInput(BaseModel):
|
||||
return values
|
||||
|
||||
|
||||
class RegistrationEndpoint(BaseEndpoint):
|
||||
|
||||
async def on_post(self, user_data: RegistrationInput) -> User:
|
||||
class RegistrationEndpoint:
|
||||
@staticmethod
|
||||
async def on_post(user_data: RegistrationInput, backgorund_tasks: BackgroundTasks) -> User:
|
||||
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:
|
||||
raise HTTPException(status_code=422, detail="User already exists")
|
||||
|
||||
model = await User.create(self.db, {
|
||||
model = await User.create(db, {
|
||||
"name": user_data.name,
|
||||
"email": user_data.email,
|
||||
"password_hashed": bcrypt.hash(user_data.password),
|
||||
"description": ""
|
||||
})
|
||||
|
||||
await self.db.commit()
|
||||
backgorund_tasks.add_task(send_email, WelcomeEmail(to=user_data.email))
|
||||
|
||||
return model.as_dict()
|
||||
|
||||
@@ -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")
|
||||
Vendored
+4
-2
@@ -1,5 +1,4 @@
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
from typing import List, Dict
|
||||
import aiohttp
|
||||
|
||||
|
||||
@@ -23,3 +22,6 @@ class ElevationAPI:
|
||||
"lng": loc['longitude'],
|
||||
"elevation": loc['elevation']
|
||||
} for loc in response['results']]
|
||||
|
||||
|
||||
elevation_api = ElevationAPI()
|
||||
|
||||
Vendored
+70
-14
@@ -1,6 +1,6 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import List, Dict
|
||||
|
||||
from typing import List, Dict, Any
|
||||
from aiocache import cached
|
||||
from lxml import etree
|
||||
from lxml.etree import _ElementTree
|
||||
@@ -12,8 +12,55 @@ class GPXParser:
|
||||
self.gpx = etree.parse(self.file)
|
||||
self.namespace = None
|
||||
self.precision_digits = 6
|
||||
self.max_samples_per_minute = 2
|
||||
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):
|
||||
namespace = self.gpx.getroot().nsmap.get(None)
|
||||
self.namespace = {'gpx': namespace}
|
||||
@@ -22,47 +69,57 @@ class GPXParser:
|
||||
return self.gpx.xpath(path, namespaces=self.namespace)
|
||||
|
||||
@cached()
|
||||
async def get_times(self):
|
||||
nodes = self.run_xpath("//gpx:time")
|
||||
async def get_times_all(self):
|
||||
nodes = self.run_xpath("//gpx:trkpt/gpx:time")
|
||||
return [datetime.fromisoformat(node.text).astimezone() for node in nodes]
|
||||
|
||||
@cached()
|
||||
async def get_times(self):
|
||||
times = await self.get_times_all()
|
||||
return await self.sample_times(times)
|
||||
|
||||
@cached()
|
||||
async def get_coordinates(self) -> List[Dict[str, float]]:
|
||||
nodes = self.run_xpath("//gpx:trkpt")
|
||||
return [{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
|
||||
return await self.average_coordinates(
|
||||
[{"lat": float(node.attrib["lat"]), "lng": float(node.attrib['lon'])} for node in nodes]
|
||||
)
|
||||
|
||||
@cached()
|
||||
async def get_speed(self) -> List[int]:
|
||||
async def get_speed(self) -> List[float]:
|
||||
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()
|
||||
async def get_magnetic_variation(self) -> List[int]:
|
||||
async def get_magnetic_variation(self) -> List[float]:
|
||||
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()
|
||||
async def get_altitude(self) -> List[float]:
|
||||
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()
|
||||
async def get_terrain_elevation(self) -> List[float]:
|
||||
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()
|
||||
async def get_max_speed(self):
|
||||
return max(await self.get_speed())
|
||||
return max(await self.get_speed()) or 0
|
||||
|
||||
@cached()
|
||||
async def get_avg_speed(self):
|
||||
speeds = await self.get_speed()
|
||||
if not speeds:
|
||||
return 0
|
||||
|
||||
return round(sum(speeds) / len(speeds), 2)
|
||||
|
||||
@cached()
|
||||
async def get_max_altitude(self):
|
||||
return max(await self.get_altitude())
|
||||
return max(await self.get_altitude()) or 0
|
||||
|
||||
@cached()
|
||||
async def get_avg_altitude(self):
|
||||
@@ -89,5 +146,4 @@ class GPXParser:
|
||||
return self.gpx
|
||||
|
||||
def write(self, tree: _ElementTree, output: str):
|
||||
print(f"ZAPISUJI DO {output}")
|
||||
tree.write(output)
|
||||
|
||||
Vendored
+6
-1
@@ -46,7 +46,9 @@ class Weather:
|
||||
resp.raise_for_status()
|
||||
return await resp.json()
|
||||
|
||||
async def get_weather_for_hour(self, date_time: datetime.datetime, gps: Tuple[float, float]) -> Dict[str, float|str]:
|
||||
async def get_weather_for_hour(
|
||||
self, date_time: datetime.datetime, gps: Tuple[float, float]
|
||||
) -> Dict[str, float | str]:
|
||||
data = await self.download_weather_for_day(date_time.date(), gps)
|
||||
|
||||
# TODO: kontrola timezone!
|
||||
@@ -56,3 +58,6 @@ class Weather:
|
||||
result_data['datetime'] = datetime.datetime.strptime(data['hourly']['time'][idx], "%Y-%m-%dT%H:%M")
|
||||
|
||||
return result_data
|
||||
|
||||
|
||||
weather_api = Weather()
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -1,115 +1,70 @@
|
||||
from typing import List, Optional, Annotated, TYPE_CHECKING
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from strawberry.file_uploads import Upload
|
||||
from sqlalchemy import select
|
||||
from database import models
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
|
||||
from upload_utils import handle_file_upload, delete_file, get_public_url
|
||||
from ..dataloaders.flight import flights_by_aircraft_dataloader
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flight import Flight
|
||||
|
||||
AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/"
|
||||
|
||||
|
||||
@strawberry_sqlalchemy_type(models.Aircraft)
|
||||
class Aircraft:
|
||||
async def load_flights(root):
|
||||
return await flights_by_aircraft_dataloader.load(root.id)
|
||||
|
||||
photo_url: Optional[str] = strawberry.field(
|
||||
resolver=lambda root: get_public_url(f"aircrafts/{root.photo_filename}") if root.photo_filename else None
|
||||
)
|
||||
|
||||
flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field(resolver=load_flights)
|
||||
|
||||
|
||||
def get_base_query(user_id: int):
|
||||
return (
|
||||
select(models.Aircraft)
|
||||
.filter(models.Aircraft.created_by_id == user_id)
|
||||
.filter(models.Aircraft.deleted.is_(False))
|
||||
)
|
||||
from decorators.endpoints import authenticated_user_only, allow_public
|
||||
from decorators.error_logging import error_logging
|
||||
from .helpers.pagination import get_pagination_window, PaginationWindow
|
||||
from .resolvers.aircraft import AircraftMutationResolver, AircraftQueryResolver
|
||||
from graphql_schema.entities.types.mutation_input import CreateAircraftInput, EditAircraftInput
|
||||
from graphql_schema.entities.types.types import Aircraft
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class AircraftQueries:
|
||||
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def aircrafts(root, info) -> List[Aircraft]:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.order_by(models.Aircraft.id.desc())
|
||||
async def aircrafts(root, info, limit: int, offset: int = 0) -> PaginationWindow[Aircraft]:
|
||||
query = AircraftQueryResolver().get_query(
|
||||
info.context.user_id,
|
||||
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()
|
||||
@authenticated_user_only()
|
||||
async def aircraft(root, info, id: int) -> Aircraft:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.filter(models.Aircraft.id == id)
|
||||
@error_logging
|
||||
@allow_public
|
||||
async def aircraft(
|
||||
root, info,
|
||||
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
|
||||
class CreateAircraftMutation:
|
||||
@strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename'])
|
||||
class CreateAircraftInput:
|
||||
photo: Optional[Upload]
|
||||
|
||||
class AircraftMutation:
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft:
|
||||
# TODO: kontrola organizace
|
||||
|
||||
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]
|
||||
return await AircraftMutationResolver().create(info.context, input)
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft:
|
||||
# TODO: kontrola organizace
|
||||
# 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:
|
||||
return await AircraftMutationResolver().update(id, user_id=info.context.user_id, data=input)
|
||||
|
||||
@strawberry.mutation
|
||||
@authenticated_user_only()
|
||||
async def delete_aircraft(self, info, id: int) -> Aircraft:
|
||||
# TODO: kontrola opravneni na akci
|
||||
|
||||
return await models.Aircraft.update(info.context.db, id=id, data=dict(deleted=True))
|
||||
return await AircraftMutationResolver().delete(info.context.user_id, id)
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
from typing import List
|
||||
import strawberry
|
||||
from sqlalchemy import select, or_
|
||||
from database import models
|
||||
from decorators.error_logging import error_logging
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
|
||||
|
||||
|
||||
@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),
|
||||
))
|
||||
)
|
||||
from graphql_schema.entities.resolvers.base import BaseQueryResolver
|
||||
from graphql_schema.entities.types.types import Airport
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class AirportQueries:
|
||||
|
||||
@strawberry.field
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def airports(root, info) -> List[Airport]:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.order_by(models.Airport.id.desc())
|
||||
)
|
||||
return await BaseQueryResolver(Airport, models.Airport).get_list(info.context.user_id)
|
||||
|
||||
return (await info.context.db.scalars(query)).all()
|
||||
|
||||
@strawberry.field
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def airport(root, info, id: int) -> Airport:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.filter(models.Airport.id == id)
|
||||
return await BaseQueryResolver(Airport, models.Airport).get_one(
|
||||
object_id=id,
|
||||
user_id=info.context.user_id
|
||||
)
|
||||
return (await info.context.db.scalars(query)).one()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,82 +1,68 @@
|
||||
from typing import List, Annotated, TYPE_CHECKING
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from sqlalchemy import select
|
||||
from graphql import GraphQLError
|
||||
from strawberry.types import Info
|
||||
from database import models
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.dataloaders.flight import flights_by_copilot_dataloader
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flight import Flight
|
||||
|
||||
|
||||
@strawberry_sqlalchemy_type(models.Copilot)
|
||||
class 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())
|
||||
)
|
||||
from decorators.error_logging import error_logging
|
||||
from decorators.endpoints import authenticated_user_only, allow_public
|
||||
from graphql_schema.entities.helpers.detail import get_detail_filters
|
||||
from graphql_schema.entities.resolvers.base import BaseMutationResolver
|
||||
from graphql_schema.entities.resolvers.copilot import CopilotQueryResolver
|
||||
from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput
|
||||
from graphql_schema.entities.types.types import Copilot
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class CopilotQueries:
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def copilots(root, info) -> List[Copilot]:
|
||||
return (await info.context.db.scalars(
|
||||
get_base_query(info.context.user_id)
|
||||
)).all()
|
||||
async def copilots(root, info: Info) -> List[Copilot]:
|
||||
return await CopilotQueryResolver().get_list(info.context.user_id)
|
||||
|
||||
@strawberry.field()
|
||||
@authenticated_user_only()
|
||||
async def copilot(root, info, id: int) -> Copilot:
|
||||
return (await info.context.db.scalars(
|
||||
get_base_query(info.context.user_id)
|
||||
.filter(models.Copilot.id == id)
|
||||
)).one()
|
||||
@error_logging
|
||||
@allow_public
|
||||
async def copilot(
|
||||
root, info: Info,
|
||||
id: Optional[int] = None,
|
||||
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
|
||||
class CreateCopilotMutation:
|
||||
|
||||
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
|
||||
class CreateCopilotInput:
|
||||
pass
|
||||
|
||||
class CopilotMutation:
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot:
|
||||
input_data = input.to_dict()
|
||||
return await models.Copilot.create(
|
||||
info.context.db,
|
||||
data=dict(
|
||||
**input_data,
|
||||
created_by_id=info.context.user_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class EditCopilotMutation:
|
||||
|
||||
@strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"])
|
||||
class EditCopilotInput:
|
||||
pass
|
||||
return await BaseMutationResolver(Copilot, models.Copilot).create(info.context, data=input)
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot:
|
||||
copilot = (await info.context.db.scalars(
|
||||
get_base_query(info.context.user_id).filter(models.Copilot.id == id)
|
||||
)).one()
|
||||
|
||||
return await models.Copilot.update(info.context.db, obj=copilot, data=input.to_dict())
|
||||
return await BaseMutationResolver(Copilot, models.Copilot).update(id, input, info.context.user_id)
|
||||
|
||||
@@ -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)
|
||||
@@ -1,317 +1,87 @@
|
||||
import asyncio
|
||||
from datetime import timedelta, datetime
|
||||
from typing import List, Optional, Annotated, TYPE_CHECKING, Tuple
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from fastapi import HTTPException
|
||||
from lxml import etree
|
||||
from sqlalchemy import select, insert, delete
|
||||
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 graphql import GraphQLError
|
||||
|
||||
from decorators.endpoints import authenticated_user_only, allow_public
|
||||
from decorators.error_logging import error_logging
|
||||
from external.gpx_parser import GPXParser
|
||||
from graphql_schema.dataloaders.aircraft import aircraft_dataloader
|
||||
from graphql_schema.dataloaders.airport import airport_dataloader
|
||||
from graphql_schema.dataloaders.copilots import flight_copilots_dataloader
|
||||
from graphql_schema.dataloaders.photos import photos_dataloader, cover_photo_loader
|
||||
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
|
||||
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
|
||||
from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput
|
||||
from graphql_schema.entities.types.types import Flight
|
||||
from .helpers.detail import get_detail_filters
|
||||
from .helpers.pagination import PaginationWindow, get_pagination_window
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class FlightQueries:
|
||||
|
||||
@strawberry.field()
|
||||
async def flights(root, info, username: Optional[str] = None) -> List[Flight]:
|
||||
if not info.context.user_id and not username:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED, f"user_id={info.context.user_id}, {username=}")
|
||||
@error_logging
|
||||
@allow_public
|
||||
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 = (
|
||||
get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id))
|
||||
.order_by(models.Flight.id.desc())
|
||||
return await get_pagination_window(
|
||||
query=query,
|
||||
item_type=Flight,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return (await info.context.db.scalars(query)).all()
|
||||
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
async def flight(root, info, id: int, username: Optional[str] = None) -> Flight:
|
||||
if not info.context.user_id and not username:
|
||||
raise HTTPException(HTTP_401_UNAUTHORIZED)
|
||||
@allow_public
|
||||
async def flight(
|
||||
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 = (
|
||||
get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id))
|
||||
.filter(models.Flight.id == id)
|
||||
return await FlightQueryResolver().get_one(
|
||||
user_id=info.context.user_id,
|
||||
only_public=public,
|
||||
**filter_params
|
||||
)
|
||||
return (await info.context.db.scalars(query)).one()
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class CreateFlightMutation:
|
||||
@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
|
||||
|
||||
class FlightMutation:
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def create_flight(self, info, input: CreateFlightInput) -> Flight:
|
||||
db = info.context.db
|
||||
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
|
||||
return await FlightMutationResolver().create(info.context, input)
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight:
|
||||
db = info.context.db
|
||||
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:
|
||||
return await FlightMutationResolver().update(info.context, id, input)
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def delete_flight(self, info, id: int) -> Flight:
|
||||
flight = (
|
||||
(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
|
||||
return await FlightMutationResolver().delete(info.context.user_id, id)
|
||||
|
||||
@@ -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())
|
||||
@@ -1,156 +1,90 @@
|
||||
from typing import List, Optional, Annotated, TYPE_CHECKING
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
from sqlalchemy import select, update
|
||||
from strawberry.file_uploads import Upload
|
||||
from database import models
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.dataloaders.poi import poi_dataloader
|
||||
from graphql_schema.entities.poi import PointOfInterest
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
|
||||
from graphql_schema.types import ComboboxInput
|
||||
from upload_utils import get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists, resize_image
|
||||
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}"
|
||||
from decorators.endpoints import authenticated_user_only, allow_public, raise_unauthorized
|
||||
from decorators.error_logging import error_logging
|
||||
from graphql_schema.entities.resolvers.base import BaseQueryResolver
|
||||
from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver
|
||||
from graphql_schema.entities.types.types import Photo
|
||||
from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class PhotoQueries:
|
||||
@strawberry.field()
|
||||
async def photos(root, info) -> List[Photo]:
|
||||
query = get_base_query(info.context.user_id)
|
||||
return (await info.context.db.scalars(query)).all()
|
||||
@error_logging
|
||||
@allow_public
|
||||
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
|
||||
class UploadPhotoMutation:
|
||||
@strawberry.input
|
||||
class UploadPhotoInput:
|
||||
photo: Upload
|
||||
flight_id: int
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
point_of_interest: Optional[ComboboxInput] = None
|
||||
|
||||
class PhotoMutation:
|
||||
@strawberry.mutation
|
||||
@authenticated_user_only()
|
||||
@error_logging
|
||||
async def upload_photo(self, info, input: UploadPhotoInput) -> Photo:
|
||||
path = get_photo_basepath(input.flight_id)
|
||||
filename = await handle_file_upload(input.photo, path)
|
||||
if info.context.user_id is None and not input.copilot_upload_token:
|
||||
raise_unauthorized()
|
||||
|
||||
info.context.background_tasks.add_task(resize_image, path=path, filename=filename, new_width=2500)
|
||||
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
|
||||
return await PhotoMutationResolver().upload(info, input)
|
||||
|
||||
@strawberry.mutation()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def edit_photo(self, info, id: int, input: EditPhotoInput) -> Photo:
|
||||
query = get_base_query(info.context.user_id)
|
||||
photo = (await info.context.db.scalars(query.filter(models.Photo.id == id))).one()
|
||||
return await PhotoMutationResolver().update(id, input, info.context.user_id)
|
||||
|
||||
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()
|
||||
@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()
|
||||
async def delete_photo(self, info, id: int) -> Photo:
|
||||
query = get_base_query(info.context.user_id)
|
||||
photo = (await info.context.db.scalars(query.filter(models.Photo.id == id))).one()
|
||||
async def adjust_photo(self, info, id: int, adjustment: AdjustmentInput) -> Photo:
|
||||
return await PhotoMutationResolver().adjust(id, info=info, user_id=info.context.user_id, adjustment=adjustment)
|
||||
|
||||
base_path = get_photo_basepath(photo.flight_id)
|
||||
try:
|
||||
delete_file(f"{base_path}/{photo.filename}")
|
||||
delete_file(f"{base_path}/thumbs/{photo.filename}")
|
||||
except Exception as e:
|
||||
print(e)
|
||||
@strawberry.mutation()
|
||||
@error_logging
|
||||
async def delete_photo(self, info, id: int, copilot_upload_token: Optional[str] = None) -> Photo:
|
||||
if info.context.user_id is None and not copilot_upload_token:
|
||||
raise_unauthorized()
|
||||
|
||||
await info.context.db.delete(photo)
|
||||
|
||||
return photo
|
||||
return await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id)
|
||||
|
||||
@@ -1,138 +1,100 @@
|
||||
from typing import List, Optional, TYPE_CHECKING, Annotated
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from sqlalchemy import select, or_
|
||||
from database import models
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.dataloaders.flight import flight_by_poi_dataloader
|
||||
from graphql_schema.dataloaders.photos import poi_photos_dataloader
|
||||
from graphql_schema.dataloaders.poi import poi_type_dataloader
|
||||
from graphql_schema.entities.helpers.flight import handle_combobox_save
|
||||
from graphql_schema.entities.poi_type import PointOfInterestType
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input
|
||||
from graphql_schema.types import ComboboxInput
|
||||
|
||||
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
|
||||
from decorators.endpoints import authenticated_user_only, allow_public
|
||||
from database.transaction import get_session
|
||||
from decorators.error_logging import error_logging
|
||||
from graphql_schema.entities.helpers.combobox import handle_combobox_save
|
||||
from graphql_schema.entities.helpers.detail import get_detail_filters
|
||||
from graphql_schema.entities.helpers.pagination import get_pagination_window, PaginationWindow
|
||||
from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver
|
||||
from graphql_schema.entities.types.types import PointOfInterest
|
||||
from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class PointOfInterestQueries:
|
||||
|
||||
@strawberry.field()
|
||||
@authenticated_user_only()
|
||||
async def points_of_interest(root, info) -> List[PointOfInterest]:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.order_by(models.PointOfInterest.id.desc())
|
||||
@error_logging
|
||||
@allow_public
|
||||
async def points_of_interest(
|
||||
root, info,
|
||||
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()
|
||||
@authenticated_user_only()
|
||||
async def point_of_interest(root, info, id: int) -> PointOfInterest:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.filter(models.PointOfInterest.id == id)
|
||||
@allow_public
|
||||
async def point_of_interest(
|
||||
root, info,
|
||||
url_slug: Optional[str] = None,
|
||||
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
|
||||
class CreatePointOfInterestMutation:
|
||||
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'])
|
||||
class CreatePointOfInterestInput:
|
||||
type: Optional[ComboboxInput] = None
|
||||
|
||||
class PointOfInterestMutation:
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def create_point_of_interest(root, info, input: CreatePointOfInterestInput) -> PointOfInterest:
|
||||
input_data = input.to_dict()
|
||||
|
||||
async with get_session() as db:
|
||||
if input.type:
|
||||
input_data['type_id'] = await handle_combobox_save(
|
||||
info.context.db,
|
||||
models.PointOfInterestType,
|
||||
input.type,
|
||||
info.context.user_id
|
||||
db, models.PointOfInterestType, input.type, info.context.user_id
|
||||
)
|
||||
|
||||
return await models.PointOfInterest.create(
|
||||
info.context.db,
|
||||
data=dict(
|
||||
**input_data,
|
||||
created_by_id=info.context.user_id,
|
||||
input_data['created_by_id'] = info.context.user_id
|
||||
return await BaseMutationResolver(PointOfInterest, models.PointOfInterest)._do_create(
|
||||
db, input_data
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class EditPointOfInterestMutation:
|
||||
@strawberry_sqlalchemy_input(models.PointOfInterest, exclude_fields=['id', 'type_id'])
|
||||
class EditPointOfInterestInput:
|
||||
type: Optional[ComboboxInput] = None
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def edit_point_of_interest(root, info, id: int, input: EditPointOfInterestInput) -> PointOfInterest:
|
||||
# TODO: kontrola organizace
|
||||
input_data = input.to_dict()
|
||||
|
||||
if input.type is not None:
|
||||
input_data['type_id'] = await handle_combobox_save(
|
||||
info.context.db,
|
||||
models.PointOfInterestType,
|
||||
input.type,
|
||||
info.context.user_id
|
||||
query = BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_query(
|
||||
user_id=info.context.user_id, object_id=id, only_public=False
|
||||
)
|
||||
|
||||
poi = (
|
||||
await info.context.db.scalars(
|
||||
get_base_query(info.context.user_id, only_my=True)
|
||||
.filter(models.PointOfInterest.id == id))
|
||||
).one()
|
||||
return await models.PointOfInterest.update(info.context.db, obj=poi, data=input_data)
|
||||
async with get_session() as db:
|
||||
if input.type is not None:
|
||||
input_data['type_id'] = await handle_combobox_save(
|
||||
db, models.PointOfInterestType, input.type, info.context.user_id
|
||||
)
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class DeletePointOfInterestMutation:
|
||||
poi = (await db.scalars(query)).one()
|
||||
updated_poi = await models.PointOfInterest.update(db, obj=poi, data=input_data)
|
||||
return PointOfInterest(**updated_poi.as_dict())
|
||||
|
||||
@strawberry.mutation
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
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 models.PointOfInterest.update(info.context.db, obj=poi, data=dict(deleted=True))
|
||||
return await BaseMutationResolver(PointOfInterest, models.PointOfInterest).delete(info.context.user_id, id=id)
|
||||
|
||||
@@ -1,55 +1,29 @@
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
import strawberry
|
||||
from sqlalchemy import select, or_
|
||||
from database import models
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
|
||||
from graphql_schema.types import ComboboxInput
|
||||
|
||||
|
||||
@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
|
||||
from decorators.error_logging import error_logging
|
||||
from graphql_schema.entities.resolvers.base import BaseQueryResolver
|
||||
from graphql_schema.entities.types.types import PointOfInterestType
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class PointOfInterestTypeQueries:
|
||||
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def point_of_interest_types(root, info) -> List[PointOfInterestType]:
|
||||
query = (
|
||||
get_base_query(info.context.user_id, only_my=False)
|
||||
.order_by(models.PointOfInterestType.id.desc())
|
||||
)
|
||||
|
||||
return (await info.context.db.scalars(query)).all()
|
||||
return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_list(info.context.user_id)
|
||||
|
||||
@strawberry.field()
|
||||
@error_logging
|
||||
@authenticated_user_only()
|
||||
async def point_of_interest_type(root, info, id: int) -> PointOfInterestType:
|
||||
query = (
|
||||
get_base_query(info.context.user_id)
|
||||
.filter(models.PointOfInterestType.id == id)
|
||||
return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_one(
|
||||
object_id=id,
|
||||
user_id=info.context.user_id
|
||||
)
|
||||
return (await info.context.db.scalars(query)).one()
|
||||
|
||||
#
|
||||
# @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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -1,35 +1,17 @@
|
||||
from functools import wraps
|
||||
from typing import Optional
|
||||
import strawberry
|
||||
from graphql import GraphQLError
|
||||
from passlib.hash import bcrypt
|
||||
from sqlalchemy import select
|
||||
from strawberry.file_uploads import Upload
|
||||
from config import API_URL
|
||||
from background_jobs.photo import resize_photo
|
||||
from database import models
|
||||
from database.models import User
|
||||
from decorators.endpoints import authenticated_user_only
|
||||
from decorators.error_logging import error_logging
|
||||
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
|
||||
from upload_utils import handle_file_upload, delete_file, get_public_url, resize_image
|
||||
|
||||
|
||||
@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)
|
||||
from database.transaction import get_session
|
||||
from graphql_schema.entities.types.types import User
|
||||
from utils.file import delete_file
|
||||
from utils.upload import handle_file_upload
|
||||
|
||||
|
||||
@strawberry.type
|
||||
@@ -40,16 +22,23 @@ class UserQueries:
|
||||
if len(username) == 0:
|
||||
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()
|
||||
@authenticated_user_only()
|
||||
@error_logging
|
||||
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)
|
||||
)).one()
|
||||
|
||||
return User(**user_model.as_dict())
|
||||
|
||||
|
||||
@strawberry.type
|
||||
class EditUserMutation:
|
||||
@@ -66,19 +55,24 @@ class EditUserMutation:
|
||||
@strawberry.mutation
|
||||
@authenticated_user_only()
|
||||
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)
|
||||
)).one()
|
||||
|
||||
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 user.avatar_image_filename:
|
||||
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)
|
||||
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:
|
||||
@@ -86,9 +80,8 @@ class EditUserMutation:
|
||||
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)
|
||||
|
||||
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:
|
||||
@@ -97,4 +90,5 @@ class EditUserMutation:
|
||||
|
||||
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())
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
from strawberry.tools import merge_types
|
||||
from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation
|
||||
from graphql_schema.entities.copilot import CreateCopilotMutation, EditCopilotMutation
|
||||
from graphql_schema.entities.flight import CreateFlightMutation, EditFlightMutation, DeleteFlightMutation
|
||||
from graphql_schema.entities.photo import UploadPhotoMutation, DeletePhotoMutation, EditPhotoMutation
|
||||
from graphql_schema.entities.poi import CreatePointOfInterestMutation, EditPointOfInterestMutation
|
||||
from graphql_schema.entities.aircraft import AircraftMutation
|
||||
from graphql_schema.entities.copilot import CopilotMutation
|
||||
from graphql_schema.entities.event import EventMutation
|
||||
from graphql_schema.entities.flight import FlightMutation
|
||||
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
|
||||
|
||||
Mutation = merge_types("Mutation", (
|
||||
CreateAircraftMutation,
|
||||
EditAircraftMutation,
|
||||
DeleteAircraftMutation,
|
||||
EditFlightMutation,
|
||||
DeleteFlightMutation,
|
||||
CreateFlightMutation,
|
||||
UploadPhotoMutation,
|
||||
EditPhotoMutation,
|
||||
DeletePhotoMutation,
|
||||
CreatePointOfInterestMutation,
|
||||
EditPointOfInterestMutation,
|
||||
CreateCopilotMutation,
|
||||
EditCopilotMutation,
|
||||
AircraftMutation,
|
||||
FlightMutation,
|
||||
PhotoMutation,
|
||||
PointOfInterestMutation,
|
||||
CopilotMutation,
|
||||
EditUserMutation,
|
||||
EventMutation,
|
||||
OrganizationMutation,
|
||||
OrganizationUserMutation,
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
@@ -1,8 +1,12 @@
|
||||
from strawberry.tools import merge_types
|
||||
from .entities.aircraft import AircraftQueries
|
||||
from .entities.airport import AirportQueries
|
||||
from .entities.airspace import AirspaceQueries
|
||||
from .entities.copilot import CopilotQueries
|
||||
from .entities.event import EventQueries
|
||||
from .entities.flight import FlightQueries
|
||||
from .entities.organization import OrganizationQueries
|
||||
from .entities.photo import PhotoQueries
|
||||
from .entities.poi import PointOfInterestQueries
|
||||
from .entities.poi_type import PointOfInterestTypeQueries
|
||||
from .entities.user import UserQueries
|
||||
@@ -13,9 +17,13 @@ from .entities.user import UserQueries
|
||||
Query = merge_types('Query', (
|
||||
AircraftQueries,
|
||||
AirportQueries,
|
||||
AirspaceQueries,
|
||||
FlightQueries,
|
||||
CopilotQueries,
|
||||
UserQueries,
|
||||
PhotoQueries,
|
||||
PointOfInterestQueries,
|
||||
PointOfInterestTypeQueries,
|
||||
EventQueries,
|
||||
OrganizationQueries,
|
||||
))
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import dataclasses
|
||||
from typing import Set
|
||||
|
||||
import strawberry
|
||||
from fastapi_jwt import JwtAuthorizationCredentials
|
||||
from fastapi_jwt.jwt import JwtAccessBearerCookie
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from starlette.background import BackgroundTasks
|
||||
from strawberry.extensions import SchemaExtension
|
||||
from strawberry.fastapi import BaseContext
|
||||
@@ -30,8 +31,8 @@ class LoggingExtension(SchemaExtension):
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GraphQLContext(BaseContext):
|
||||
db: AsyncSession
|
||||
user_id: int
|
||||
organization_ids: Set[int]
|
||||
jwt_auth_credentials: JwtAuthorizationCredentials
|
||||
jwt: JwtAccessBearerCookie
|
||||
background_tasks: BackgroundTasks
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import typing
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
import strawberry
|
||||
import sqlalchemy
|
||||
from sqlalchemy import Column
|
||||
from logger import log
|
||||
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]]:
|
||||
@@ -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
|
||||
annotations_[name] = type_
|
||||
except NotImplementedError as e:
|
||||
print(f"Neimplementovano: {e}, {name=}")
|
||||
log.warning(f"Cannot annotate {name} in {model} for GQL type. Exception: {e}")
|
||||
|
||||
return annotations_
|
||||
|
||||
@@ -31,8 +32,8 @@ def strawberry_sqlalchemy_type(model, exclude_fields: Optional[typing.Union[List
|
||||
if exclude_fields is None:
|
||||
exclude_fields = []
|
||||
|
||||
def from_sqlalchemy_model(model: BaseModel):
|
||||
return model
|
||||
def from_sqlalchemy_model(cls, model: BaseModel):
|
||||
return cls(model)
|
||||
|
||||
def wrapper(cls):
|
||||
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(
|
||||
model,
|
||||
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:
|
||||
exclude_fields = []
|
||||
|
||||
ignored_fields = exclude_fields + ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"]
|
||||
|
||||
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
|
||||
ignored_fields = exclude_fields + BaseGraphqlInputType.base_ignored_fields
|
||||
|
||||
def wrapper(cls):
|
||||
annotations = get_annotations_for_scalars(
|
||||
@@ -72,7 +61,6 @@ def strawberry_sqlalchemy_input(
|
||||
)
|
||||
|
||||
cls.__annotations__.update(annotations)
|
||||
cls.to_dict = to_dict
|
||||
|
||||
for col, col_type in annotations.items():
|
||||
try:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user