From 014a99905588d18e6ed7464670887143099cada6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 5 Jun 2023 09:55:17 +0200 Subject: [PATCH] INIT Commit --- .flake8 | 6 + .gitignore | 10 + Dockerfile | 27 ++ alembic.ini | 108 ++++++++ alembic/README | 1 + alembic/env.py | 98 +++++++ alembic/script.py.mako | 24 ++ ...06-205749_first_db_version_de421195d45c.py | 185 ++++++++++++++ ...230506-210142_password_len_19ce22e5926b.py | 34 +++ ...20230507-214258_avatar_url_7951a7b79007.py | 28 ++ .../20230523-054229_edit_d15debffedf8.py | 38 +++ .../20230523-054726_edit_8491bd15ab4a.py | 28 ++ ...0531-201802_aircraft_photo_431adcd37681.py | 28 ++ db/init.sql | 0 docker-compose.yml | 21 ++ requirements.txt | 8 + src/__init__.py | 0 src/asgi.py | 6 + src/config.py | 3 + src/database/__init__.py | 6 + src/database/config.py | 17 ++ src/database/custom_types.py | 13 + src/database/models.py | 240 ++++++++++++++++++ src/dependencies/__init__.py | 0 src/dependencies/db.py | 7 + src/dependencies/jwt.py | 10 + src/endpoints/base.py | 3 + src/endpoints/flights.py | 17 ++ src/endpoints/init_data.py | 60 +++++ src/endpoints/login.py | 59 +++++ src/endpoints/registration.py | 45 ++++ src/graphql_schema/__init__.py | 0 src/graphql_schema/dataloaders/__init__.py | 5 + src/graphql_schema/dataloaders/aircraft.py | 16 ++ src/graphql_schema/dataloaders/copilots.py | 16 ++ src/graphql_schema/entities/__init__.py | 0 src/graphql_schema/entities/aircraft.py | 121 +++++++++ src/graphql_schema/entities/copilot.py | 17 ++ src/graphql_schema/entities/flight.py | 67 +++++ src/graphql_schema/entities/user.py | 25 ++ src/graphql_schema/mutation.py | 10 + src/graphql_schema/query.py | 15 ++ src/graphql_schema/schema.py | 42 +++ .../sqlalchemy_to_strawberry_type.py | 54 ++++ src/main.py | 127 +++++++++ uploads/.gitkeep | 0 46 files changed, 1645 insertions(+) create mode 100644 .flake8 create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/20230506-205749_first_db_version_de421195d45c.py create mode 100644 alembic/versions/20230506-210142_password_len_19ce22e5926b.py create mode 100644 alembic/versions/20230507-214258_avatar_url_7951a7b79007.py create mode 100644 alembic/versions/20230523-054229_edit_d15debffedf8.py create mode 100644 alembic/versions/20230523-054726_edit_8491bd15ab4a.py create mode 100644 alembic/versions/20230531-201802_aircraft_photo_431adcd37681.py create mode 100644 db/init.sql create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/asgi.py create mode 100644 src/config.py create mode 100644 src/database/__init__.py create mode 100644 src/database/config.py create mode 100644 src/database/custom_types.py create mode 100644 src/database/models.py create mode 100644 src/dependencies/__init__.py create mode 100644 src/dependencies/db.py create mode 100644 src/dependencies/jwt.py create mode 100644 src/endpoints/base.py create mode 100644 src/endpoints/flights.py create mode 100644 src/endpoints/init_data.py create mode 100644 src/endpoints/login.py create mode 100644 src/endpoints/registration.py create mode 100644 src/graphql_schema/__init__.py create mode 100644 src/graphql_schema/dataloaders/__init__.py create mode 100644 src/graphql_schema/dataloaders/aircraft.py create mode 100644 src/graphql_schema/dataloaders/copilots.py create mode 100644 src/graphql_schema/entities/__init__.py create mode 100644 src/graphql_schema/entities/aircraft.py create mode 100644 src/graphql_schema/entities/copilot.py create mode 100644 src/graphql_schema/entities/flight.py create mode 100644 src/graphql_schema/entities/user.py create mode 100644 src/graphql_schema/mutation.py create mode 100644 src/graphql_schema/query.py create mode 100644 src/graphql_schema/schema.py create mode 100644 src/graphql_schema/sqlalchemy_to_strawberry_type.py create mode 100644 src/main.py create mode 100644 uploads/.gitkeep diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..49af690 --- /dev/null +++ b/.flake8 @@ -0,0 +1,6 @@ +[flake8] +max-line-length = 120 +exclude = + venv + __pycache__ + alembic \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e47e9e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +.idea/ +__pycache__/ +.pytest_cache/ +*.pyc +.cache + +# app specific files and dirs +uploads/* +!uploads/.gitkeep \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fb7c563 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.10-alpine + +RUN apk update && \ + apk add --no-cache tzdata mariadb-client mariadb-dev && \ + pip install --upgrade pip +# protoze xfs uzivatel ma id=33 +RUN deluser xfs + +# uzivatel www-data v alpine neexistuje +RUN delgroup www-data && addgroup -g 33 -S www-data && adduser -u 33 -D -S -G www-data www-data + + +COPY requirements.txt /app/requirements.txt + +# required packages for pip (becasue of yarl lib) +ENV INSTALL_PACKAGES build-base linux-headers git + +RUN apk add --no-cache $INSTALL_PACKAGES +RUN pip install --no-cache-dir -r /app/requirements.txt +RUN apk del $INSTALL_PACKAGES + +RUN mkdir /app/upload -p +COPY . /app + +WORKDIR /app + +CMD ["uvicorn", "src.asgi:app", "--reload", "--port=8000", "--host=0.0.0.0", "--log-level=debug"] \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..15929e6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,108 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +file_template = %%(year)d%%(month).2d%%(day).2d-%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s_%%(rev)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = mysql+aiomysql://root:@db/ull_tracker + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..733ccdd --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,98 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +import os, sys + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(BASE_DIR+"/src") + +import database.models + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +target_metadata = database.models.BaseModel.metadata + +# other values from the config, defined by the needs of config.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + compare_type=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, compare_type=True, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/20230506-205749_first_db_version_de421195d45c.py b/alembic/versions/20230506-205749_first_db_version_de421195d45c.py new file mode 100644 index 0000000..0982ddc --- /dev/null +++ b/alembic/versions/20230506-205749_first_db_version_de421195d45c.py @@ -0,0 +1,185 @@ +"""first db version + +Revision ID: de421195d45c +Revises: +Create Date: 2023-05-06 20:57:49.053470 + +""" +from alembic import op +import sqlalchemy as sa +import database.custom_types + +# revision identifiers, used by Alembic. +revision = 'de421195d45c' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('airport', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('icao_code', sa.String(length=4), nullable=False), + sa.Column('gps_position', database.custom_types.Point(), nullable=True), + sa.Column('elevation', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=128), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('password_hashed', sa.String(length=45), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.create_table('copilot', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('license', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('number', sa.String(length=30), nullable=False), + sa.Column('valid_until', sa.DateTime(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('metar', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('airport_id', sa.Integer(), nullable=False), + sa.Column('metar', sa.Text(), nullable=False), + sa.Column('issued_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['airport_id'], ['airport.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('organization', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('point_of_interest_type', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), 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.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('aircraft', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('type', sa.String(length=30), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('point_of_interest', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('gps_position', database.custom_types.Point(), nullable=True), + sa.Column('type_id', sa.Integer(), 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.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['type_id'], ['point_of_interest_type.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('user_is_in_organization', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('organization_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('user_id', 'organization_id') + ) + op.create_table('aircraft_notes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('aircraft_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.ForeignKeyConstraint(['aircraft_id'], ['aircraft.id'], ), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('flight', + 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('takeoff_airport_id', sa.Integer(), nullable=True), + sa.Column('landing_airport_id', sa.Integer(), nullable=True), + sa.Column('aircraft_id', sa.Integer(), nullable=False), + sa.Column('copilot_id', sa.Integer(), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.Column('duration_total', sa.Integer(), nullable=False), + sa.Column('duration_pic', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['aircraft_id'], ['aircraft.id'], ), + sa.ForeignKeyConstraint(['copilot_id'], ['copilot.id'], ), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['landing_airport_id'], ['airport.id'], ), + sa.ForeignKeyConstraint(['takeoff_airport_id'], ['airport.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('flight_track', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('flight_id', sa.Integer(), nullable=False), + sa.Column('poi_id', sa.Integer(), nullable=False), + sa.Column('order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['flight_id'], ['flight.id'], ), + sa.ForeignKeyConstraint(['poi_id'], ['point_of_interest.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('photo', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=128), nullable=False), + sa.Column('filename', sa.String(length=128), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('gps_position', database.custom_types.Point(), nullable=True), + sa.Column('flight_id', sa.Integer(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['flight_id'], ['flight.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('photo') + op.drop_table('flight_track') + op.drop_table('flight') + op.drop_table('aircraft_notes') + op.drop_table('user_is_in_organization') + op.drop_table('point_of_interest') + op.drop_table('aircraft') + op.drop_table('point_of_interest_type') + op.drop_table('organization') + op.drop_table('metar') + op.drop_table('license') + op.drop_table('copilot') + op.drop_table('user') + op.drop_table('airport') + # ### end Alembic commands ### diff --git a/alembic/versions/20230506-210142_password_len_19ce22e5926b.py b/alembic/versions/20230506-210142_password_len_19ce22e5926b.py new file mode 100644 index 0000000..5a57432 --- /dev/null +++ b/alembic/versions/20230506-210142_password_len_19ce22e5926b.py @@ -0,0 +1,34 @@ +"""password len + +Revision ID: 19ce22e5926b +Revises: de421195d45c +Create Date: 2023-05-06 21:01:42.983935 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '19ce22e5926b' +down_revision = 'de421195d45c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'password_hashed', + existing_type=mysql.VARCHAR(length=45), + type_=sa.String(length=60), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'password_hashed', + existing_type=sa.String(length=60), + type_=mysql.VARCHAR(length=45), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/20230507-214258_avatar_url_7951a7b79007.py b/alembic/versions/20230507-214258_avatar_url_7951a7b79007.py new file mode 100644 index 0000000..0d6fe1e --- /dev/null +++ b/alembic/versions/20230507-214258_avatar_url_7951a7b79007.py @@ -0,0 +1,28 @@ +"""avatar url + +Revision ID: 7951a7b79007 +Revises: 19ce22e5926b +Create Date: 2023-05-07 21:42:58.261939 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7951a7b79007' +down_revision = '19ce22e5926b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('avatar_image_url', sa.String(length=128), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'avatar_image_url') + # ### end Alembic commands ### diff --git a/alembic/versions/20230523-054229_edit_d15debffedf8.py b/alembic/versions/20230523-054229_edit_d15debffedf8.py new file mode 100644 index 0000000..668b6b6 --- /dev/null +++ b/alembic/versions/20230523-054229_edit_d15debffedf8.py @@ -0,0 +1,38 @@ +"""edit + +Revision ID: d15debffedf8 +Revises: 7951a7b79007 +Create Date: 2023-05-23 05:42:29.097139 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'd15debffedf8' +down_revision = '7951a7b79007' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('aircraft', sa.Column('manufacturer', sa.Text(), server_default='', nullable=False)) + op.add_column('aircraft', sa.Column('model', sa.String(length=30), nullable=False)) + op.drop_column('aircraft', 'type') + op.alter_column('user', 'avatar_image_url', + existing_type=mysql.VARCHAR(length=128), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('user', 'avatar_image_url', + existing_type=mysql.VARCHAR(length=128), + nullable=False) + op.add_column('aircraft', sa.Column('type', mysql.VARCHAR(length=30), nullable=False)) + op.drop_column('aircraft', 'model') + op.drop_column('aircraft', 'manufacturer') + # ### end Alembic commands ### diff --git a/alembic/versions/20230523-054726_edit_8491bd15ab4a.py b/alembic/versions/20230523-054726_edit_8491bd15ab4a.py new file mode 100644 index 0000000..076abc9 --- /dev/null +++ b/alembic/versions/20230523-054726_edit_8491bd15ab4a.py @@ -0,0 +1,28 @@ +"""edit + +Revision ID: 8491bd15ab4a +Revises: d15debffedf8 +Create Date: 2023-05-23 05:47:26.866008 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8491bd15ab4a' +down_revision = 'd15debffedf8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('aircraft', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('aircraft', 'deleted') + # ### end Alembic commands ### diff --git a/alembic/versions/20230531-201802_aircraft_photo_431adcd37681.py b/alembic/versions/20230531-201802_aircraft_photo_431adcd37681.py new file mode 100644 index 0000000..2cba27d --- /dev/null +++ b/alembic/versions/20230531-201802_aircraft_photo_431adcd37681.py @@ -0,0 +1,28 @@ +"""aircraft photo + +Revision ID: 431adcd37681 +Revises: 8491bd15ab4a +Create Date: 2023-05-31 20:18:02.644970 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '431adcd37681' +down_revision = '8491bd15ab4a' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('aircraft', sa.Column('photo_filename', sa.String(length=128), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('aircraft', 'photo_filename') + # ### end Alembic commands ### diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe49c31 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + db: + image: mariadb:10.11 + ports: + - 3306:3306 + environment: + - MYSQL_USER=root + - MYSQL_DATABASE=ull_tracker + - MARIADB_ALLOW_EMPTY_ROOT_PASSWORD=true + volumes: + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + - db-data:/var/lib/mysql:rw + api: + build: + context: . + ports: + - 8000:8000 + volumes: + - .:/app +volumes: + db-data: { } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..26789dc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +strawberry-graphql[fastapi] +uvicorn +sqlalchemy[asyncio] >= 2.0.9 +aiomysql +fastapi-jwt +alembic +passlib diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/asgi.py b/src/asgi.py new file mode 100644 index 0000000..a761487 --- /dev/null +++ b/src/asgi.py @@ -0,0 +1,6 @@ +import sys + +sys.path.insert(0, "/app/src") +from .main import App # noqa + +app = App().create_app() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..20691dc --- /dev/null +++ b/src/config.py @@ -0,0 +1,3 @@ +APP_DEBUG = True +GRAPHIQL = True +APP_SECRET_KEY = "test" diff --git a/src/database/__init__.py b/src/database/__init__.py new file mode 100644 index 0000000..dab1eed --- /dev/null +++ b/src/database/__init__.py @@ -0,0 +1,6 @@ +__all__ = [ + 'async_session', + 'engine' +] + +from database.config import async_session, engine diff --git a/src/database/config.py b/src/database/config.py new file mode 100644 index 0000000..a82469e --- /dev/null +++ b/src/database/config.py @@ -0,0 +1,17 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + + +def create_db_engine( + user: str = "root", + password: str = "", + host: str = "db", + port: int = 3306, + database: str = "ull_tracker", +): + database_url = f'mysql+aiomysql://{user}:{password}@{host}:{port}/{database}?charset=utf8' + return create_async_engine(database_url, future=True, echo=True) + + +engine = create_db_engine() +async_session = sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) diff --git a/src/database/custom_types.py b/src/database/custom_types.py new file mode 100644 index 0000000..e7bd0c6 --- /dev/null +++ b/src/database/custom_types.py @@ -0,0 +1,13 @@ +from sqlalchemy import func +from sqlalchemy.types import UserDefinedType + + +class Point(UserDefinedType): + def get_col_spec(self): + return 'POINT' + + def bind_expression(self, bindvalue): + return func.ST_GeomFromText(bindvalue, type_=self) + + def column_expression(self, col): + return func.ST_AsText(col, type_=self) diff --git a/src/database/models.py b/src/database/models.py new file mode 100644 index 0000000..af91180 --- /dev/null +++ b/src/database/models.py @@ -0,0 +1,240 @@ +import datetime +from typing import Set +from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select +from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column +from database.custom_types import Point +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 + } + + @classmethod + async def create(cls, db_session: AsyncSession, data: dict): + model = cls(**data) + db_session.add(model) + await db_session.commit() + + return model + + @classmethod + async def update(cls, db_session: AsyncSession, id: int, data: dict): + obj = (await db_session.scalars(select(cls).filter_by(id=id))).one() + for key, value in data.items(): + if getattr(obj, key) != value: + setattr(obj, key, value) + + await db_session.commit() + + return obj + + +# TODO: doplnit GPX k letu, pocasi k letu (podle lokality, mozna do FlightTrack) + +user_is_in_organization = Table( + "user_is_in_organization", + BaseModel.metadata, + Column("user_id", Integer, ForeignKey("user.id"), primary_key=True), + Column("organization_id", Integer, ForeignKey("organization.id"), primary_key=True) +) + + +class Airport(BaseModel): + __tablename__ = "airport" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + icao_code: Mapped[str] = mapped_column(String(4), nullable=False) + gps_position: Mapped[Point] = mapped_column(Point, nullable=True) + elevation: Mapped[int] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + metars: Mapped['Metar'] = relationship(back_populates="airport") + + +class PointOfInterestType(BaseModel): + __tablename__ = "point_of_interest_type" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), 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() + + +class PointOfInterest(BaseModel): + __tablename__ = "point_of_interest" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + gps_position: Mapped[Point] = mapped_column(Point, nullable=True) + type_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest_type.id")) + 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()) + + type: Mapped[PointOfInterestType] = relationship() + created_by: Mapped['User'] = relationship() + + +class Photo(BaseModel): + __tablename__ = "photo" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + filename: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + gps_position: Mapped[Point] = mapped_column(Point, 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_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + flight: Mapped['Flight'] = relationship(back_populates="photos") + created_by: Mapped['User'] = relationship() + + +class Aircraft(BaseModel): + __tablename__ = "aircraft" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + photo_filename: Mapped[str] = mapped_column(String(128), nullable=True) + manufacturer: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + model: Mapped[str] = mapped_column(String(30), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + organization_id: Mapped[int] = mapped_column(Integer, ForeignKey('organization.id'), nullable=True) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') + + organization: Mapped['Organization'] = relationship() + flights: Mapped[Set['Flight']] = relationship() + created_by: Mapped['User'] = relationship() + notes: Mapped['AircraftNotes'] = relationship() + + +class AircraftNotes(BaseModel): + __tablename__ = "aircraft_notes" + + id: Mapped[int] = mapped_column(primary_key=True) + aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey("aircraft.id"), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + created_by: Mapped['User'] = relationship() + aircraft: Mapped['Aircraft'] = relationship(back_populates="notes") + + +class Organization(BaseModel): + __tablename__ = "organization" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + users: Mapped[Set['User']] = relationship(back_populates='organizations', secondary=user_is_in_organization) + created_by: Mapped['User'] = relationship() + + +class FlightTrack(BaseModel): + __tablename__ = "flight_track" + + id: Mapped[int] = mapped_column(primary_key=True) + flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False) + poi_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=False) + order: Mapped[int] = mapped_column(Integer) + + flight: Mapped['Flight'] = relationship() + point_of_interest: Mapped['PointOfInterest'] = 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=True) + landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True) + aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey('aircraft.id')) + copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + duration_total: Mapped[int] = mapped_column(Integer, nullable=False) + duration_pic: Mapped[int] = mapped_column(Integer, nullable=False) + + takeoff_airport: Mapped['Airport'] = relationship(foreign_keys=[takeoff_airport_id]) + landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id]) + copilot: Mapped['Copilot'] = relationship(back_populates="flights") + aircraft: Mapped['Aircraft'] = relationship(back_populates="flights") + photos: Mapped[Set['Photo']] = relationship() + user: Mapped['User'] = relationship(back_populates="flights") + created_by: Mapped['User'] = relationship() + # flight_track: Mapped[List['PointOfInterest']] = relationship(secondary=FlightTrack) + + +class Copilot(BaseModel): + __tablename__ = "copilot" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + flights: Mapped[Set['Flight']] = relationship(back_populates="copilot") + created_by: Mapped['User'] = relationship() + + +class Metar(BaseModel): + __tablename__ = "metar" + + id: Mapped[int] = mapped_column(primary_key=True) + airport_id: Mapped[int] = mapped_column(Integer, ForeignKey('airport.id')) + metar: Mapped[str] = mapped_column(Text, nullable=False) + issued_at: Mapped[datetime] = mapped_column(DateTime) + + airport: Mapped['Airport'] = relationship(back_populates="metars") + + +class License(BaseModel): + __tablename__ = "license" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False) + number: Mapped[str] = mapped_column(String(30), nullable=False) + valid_until: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + user: Mapped['User'] = relationship(back_populates="licences") + created_by: Mapped['User'] = relationship() + + +class User(BaseModel): + __tablename__ = "user" + excluded_columns_in_dict = ('password_hashed',) + + 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) + avatar_image_url: Mapped[str] = mapped_column(String(128), nullable=True) + password_hashed: Mapped[str] = mapped_column(String(60), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + + licences: Mapped[Set['License']] = relationship(back_populates="user") + flights: Mapped[Set['Flight']] = relationship(back_populates="user") + organizations: Mapped[Set['Organization']] = relationship(back_populates="users", secondary=user_is_in_organization) diff --git a/src/dependencies/__init__.py b/src/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dependencies/db.py b/src/dependencies/db.py new file mode 100644 index 0000000..bbbaefe --- /dev/null +++ b/src/dependencies/db.py @@ -0,0 +1,7 @@ +from database import async_session + + +async def db_session(): + async with async_session() as session: + async with session.begin(): + yield session diff --git a/src/dependencies/jwt.py b/src/dependencies/jwt.py new file mode 100644 index 0000000..7bf4ea7 --- /dev/null +++ b/src/dependencies/jwt.py @@ -0,0 +1,10 @@ +# 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) diff --git a/src/endpoints/base.py b/src/endpoints/base.py new file mode 100644 index 0000000..35cbdad --- /dev/null +++ b/src/endpoints/base.py @@ -0,0 +1,3 @@ +class BaseEndpoint: + def __init__(self, db): + self.db = db diff --git a/src/endpoints/flights.py b/src/endpoints/flights.py new file mode 100644 index 0000000..46c9afa --- /dev/null +++ b/src/endpoints/flights.py @@ -0,0 +1,17 @@ +from sqlalchemy import select, desc + +from database.models import Flight +from endpoints.base import BaseEndpoint + + +class FlightsEndpoint(BaseEndpoint): + + async def resolve(self): + self.db.add(Flight(name="test")) + await self.db.flush() + + data = await self.db.execute(select(Flight).order_by(desc(Flight.id))) + model = data.scalars().first() + return { + "status": model + } diff --git a/src/endpoints/init_data.py b/src/endpoints/init_data.py new file mode 100644 index 0000000..e978ca0 --- /dev/null +++ b/src/endpoints/init_data.py @@ -0,0 +1,60 @@ +from database.models import User, Flight, Copilot, Airport, Aircraft +from endpoints.base import BaseEndpoint + + +class InitDataEndpoint(BaseEndpoint): + + async def on_get(self): + user_ids = [] + copilot_ids = [] + flight_ids = [] + + users = [ + User(avatar_image_url="", name="Karel Vomacka", email="a@test.cz", password_hashed="****"), + User(avatar_image_url="", name="Karel Novak", email="b@test.cz", password_hashed="****"), + User(avatar_image_url="", name="Franta Pavel", email="c@test.cz", password_hashed="****"), + ] + + airport = Airport(name="Letiste Letnany", icao_code="LKLT") + self.db.add(airport) + + for user in users: + self.db.add(user) + await self.db.flush() + user_ids.append(user.id) + + aircraft = Aircraft(name="OK-AUR28", type="Bristell NG5", description="", created_by=user) + self.db.add(aircraft) + + copilot = None + if user.name == 'Franta Pavel': + copilot = Copilot(name="Copilot test", created_by_id=user.id) + self.db.add(copilot) + await self.db.flush() + + copilot_ids.append(copilot.id) + + flight = Flight(name="test flight", description="Testovaci popis", duration_total=65, duration_pic=65, + takeoff_airport=airport, landing_airport=airport, aircraft=aircraft, created_by_id=user.id, + copilot_id=copilot.id if copilot else None) + self.db.add(flight) + await self.db.flush() + flight_ids.append(flight.id) + + return { + "user_ids": user_ids, + "copilot_ids": copilot_ids, + "flight_ids": flight_ids, + 'airport_id': airport.id + } + + # + # + # self.db.add(Flight(name="test")) + # await self.db.flush() + # + # data = await self.db.execute(select(Flight).order_by(desc(Flight.id))) + # model = data.scalars().first() + # return { + # "status": model + # } diff --git a/src/endpoints/login.py b/src/endpoints/login.py new file mode 100644 index 0000000..36a8519 --- /dev/null +++ b/src/endpoints/login.py @@ -0,0 +1,59 @@ +from fastapi import HTTPException +from fastapi_jwt import JwtAuthorizationCredentials +from fastapi_jwt.jwt import JwtAccess, JwtRefresh +from passlib.hash import bcrypt +from sqlalchemy import select +from starlette.responses import Response + +from database.models import User +from endpoints.base import BaseEndpoint +from pydantic import BaseModel + + +class LoginInput(BaseModel): + email: str + password: str + + +class LoginEndpoint(BaseEndpoint): + + def __init__(self, db, access_token: JwtAccess, refresh_token: JwtRefresh): + super().__init__(db) + self.access_security = access_token + self.refresh_security = refresh_token + + 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() + + if not logged_user: + raise HTTPException(status_code=401, detail="Invalid user") + + if not bcrypt.verify(user_data.password, logged_user.password_hashed): + raise HTTPException(status_code=401, detail="Bad username or password") + + subject = {"id": logged_user.id, "email": logged_user.email} + access_token = self.access_security.create_access_token(subject=subject) + refresh_token = self.refresh_security.create_refresh_token(subject=subject) + + self.access_security.set_access_cookie(resp, access_token) + self.refresh_security.set_refresh_cookie(resp, refresh_token) + + return { + "user": logged_user.as_dict(), + "access_token": access_token, + "refresh_token": refresh_token + } + + +class MeEndpoint(BaseEndpoint): + + def __init__(self, db, credentials: JwtAuthorizationCredentials): + super().__init__(db) + self.credentials = credentials + + async def on_get(self) -> dict: + query = select(User).filter_by(id=self.credentials['id']) + user = (await self.db.scalars(query)).first() + + return user.as_dict() diff --git a/src/endpoints/registration.py b/src/endpoints/registration.py new file mode 100644 index 0000000..1cc7845 --- /dev/null +++ b/src/endpoints/registration.py @@ -0,0 +1,45 @@ +import re + +from fastapi import HTTPException +from sqlalchemy import select +from typing import Optional +from pydantic import BaseModel, root_validator, Field +from database.models import User +from endpoints.base import BaseEndpoint +from passlib.hash import bcrypt + + +class RegistrationInput(BaseModel): + email: str = Field(..., min_length=4) + name: Optional[str] + password: str + + @root_validator() + def validate_email(cls, values): + email = values.get("email") or "" + + if email and not re.match(r"(.+)@(.+)\..{2,6}", email): + raise ValueError("Specified e-mail is not valid!") + + return values + + +class RegistrationEndpoint(BaseEndpoint): + + async def on_post(self, user_data: RegistrationInput) -> User: + query = select(User).filter_by(email=user_data.email) + existing_user = (await self.db.scalars(query)).first() + + if existing_user: + raise HTTPException(status_code=422, detail="User already exists") + + model = User( + name=user_data.name, + email=user_data.email, + password_hashed=bcrypt.hash(user_data.password) + ) + + self.db.add(model) + await self.db.commit() + + return model.as_dict() diff --git a/src/graphql_schema/__init__.py b/src/graphql_schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_schema/dataloaders/__init__.py b/src/graphql_schema/dataloaders/__init__.py new file mode 100644 index 0000000..34f4f31 --- /dev/null +++ b/src/graphql_schema/dataloaders/__init__.py @@ -0,0 +1,5 @@ +__all__ = [ + 'copilots_dataloader' +] + +from graphql_schema.dataloaders.copilots import copilots_dataloader diff --git a/src/graphql_schema/dataloaders/aircraft.py b/src/graphql_schema/dataloaders/aircraft.py new file mode 100644 index 0000000..6bb1152 --- /dev/null +++ b/src/graphql_schema/dataloaders/aircraft.py @@ -0,0 +1,16 @@ +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) diff --git a/src/graphql_schema/dataloaders/copilots.py b/src/graphql_schema/dataloaders/copilots.py new file mode 100644 index 0000000..74121a5 --- /dev/null +++ b/src/graphql_schema/dataloaders/copilots.py @@ -0,0 +1,16 @@ +from typing import List +from sqlalchemy import select +from strawberry.dataloader import DataLoader +from database import async_session +from database.models import Copilot + + +async def load(ids: List[int]): + async with async_session() as session: + models = (await session.scalars(select(Copilot).filter(Copilot.id.in_(ids)))).all() + + models_by_id = {model.id: model for model in models} + return [models_by_id.get(id_) for id_ in ids] + + +copilots_dataloader = DataLoader(load_fn=load) diff --git a/src/graphql_schema/entities/__init__.py b/src/graphql_schema/entities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py new file mode 100644 index 0000000..1864bbe --- /dev/null +++ b/src/graphql_schema/entities/aircraft.py @@ -0,0 +1,121 @@ +import os +import uuid +from typing import List, Optional +import strawberry +from strawberry.file_uploads import Upload +from sqlalchemy import select +from database import models +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input + + +@strawberry_sqlalchemy_type(models.Aircraft) +class Aircraft: + photo_url: Optional[str] = strawberry.field( + resolver=lambda root: f"http://localhost:8000/uploads/{root.photo_filename}" if root.photo_filename else None + ) + + +def get_base_query(user_id: int): + return ( + select(models.Aircraft) + .filter(models.Aircraft.created_by_id == user_id) + .filter(models.Aircraft.deleted.is_(False)) + ) + + +@strawberry.type +class AircraftQueries: + + @strawberry.field + async def aircrafts(root, info) -> List[Aircraft]: + query = ( + get_base_query(info.context.user_id) + .order_by(models.Aircraft.id.desc()) + ) + + return (await info.context.db.scalars(query)).all() + + @strawberry.field + async def aircraft(root, info, id: int) -> Aircraft: + query = ( + get_base_query(info.context.user_id) + .filter(models.Aircraft.id == id) + ) + return (await info.context.db.scalars(query)).one() + + +def check_directories(path: str): + if not os.path.isdir(path): + os.makedirs(path) + + +async def handle_img_upload(file: Upload, path: str, filename: str): + check_directories(path) + + content = await file.read() + image = open(path + "/" + filename, "wb") + image.write(content) + image.close() + + +@strawberry.type +class CreateAircraftMutation: + @strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['id', 'photo_filename']) + class CreateAircraftInput: + photo: Optional[Upload] + + @strawberry.mutation + async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: + # TODO: kontrola organizace + + filename = None + if input.photo: + dest_path = "/app/uploads/aircrafts/" + filename = f"{uuid.uuid4()}-{input.photo.filename}" + await handle_img_upload(input.photo, dest_path, filename=filename) + + return await models.Aircraft.create( + info.context.db, + data=dict( + name=input.name, + description=input.description, + model=input.model, + manufacturer=input.manufacturer, + photo_filename=filename, + organization_id=input.organization_id, + created_by_id=info.context.user_id, + ) + ) + + +@strawberry.type +class EditAircraftMutation: + @strawberry_sqlalchemy_input(models.Aircraft, exclude_fields=['photo_filename']) + class EditAircraftInput: + photo: Optional[Upload] + + @strawberry.mutation + async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft: + # TODO: kontrola organizace + # TODO: kontrola opravneni na akci + return await models.Aircraft.update( + info.context.db, + id, + data=dict( + name=input.name, + description=input.description, + model=input.model, + manufacturer=input.manufacturer, + organization_id=input.organization_id, + ) + ) + + +@strawberry.type +class DeleteAircraftMutation: + + @strawberry.mutation + async def delete_aircraft(self, info, id: int) -> Aircraft: + # TODO: kontrola opravneni na akci + + return await models.Aircraft.update(info.context.db, id, data=dict(deleted=True)) diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py new file mode 100644 index 0000000..b126aa5 --- /dev/null +++ b/src/graphql_schema/entities/copilot.py @@ -0,0 +1,17 @@ +from typing import List +import strawberry +from sqlalchemy import select +from database.models import Copilot +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type + + +@strawberry_sqlalchemy_type(Copilot) +class CopilotType: + pass + + +@strawberry.type +class CopilotQueries: + @strawberry.field + async def pilots(root, info) -> List[CopilotType]: + return (await info.context['db'].scalars(select(Copilot))).all() diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py new file mode 100644 index 0000000..70aeb74 --- /dev/null +++ b/src/graphql_schema/entities/flight.py @@ -0,0 +1,67 @@ +from typing import List, Optional +import strawberry +from sqlalchemy import select +from database import models +from graphql_schema.dataloaders import copilots_dataloader +from graphql_schema.dataloaders.aircraft import aircraft_dataloader +from graphql_schema.entities.aircraft import Aircraft +from graphql_schema.entities.copilot import CopilotType +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input + + +# Bude se hodit: https://strawberry.rocks/docs/types/lazy + +@strawberry_sqlalchemy_type(models.Flight) +class Flight: + + async def load_aircraft(root): + return await aircraft_dataloader.load(root.aircraft_id) + + async def load_copilot(root): + return await copilots_dataloader.load(root.copilot_id) + + copilot: Optional[CopilotType] = strawberry.field(resolver=load_copilot) + aircraft: Aircraft = strawberry.field(resolver=load_aircraft) + + +@strawberry.type +class FlightQueries: + @strawberry.input + class FlightFilters: + takeoff: Optional[int] + + @strawberry.field + async def flights(root, info, filters: Optional[FlightFilters] = None) -> List[Flight]: + query = ( + select(models.Flight) + .filter(models.Flight.created_by_id == info.context.user_id) + .order_by(models.Flight.id) # TODO: desc + ) + + return (await info.context.db.scalars(query)).all() + + @strawberry.field + async def flight(root, info, id: int) -> Flight: + query = ( + select(models.Flight) + .filter(models.Flight.id == id) + .filter(models.Flight.created_by_id == info.context.user_id) + ) + return (await info.context.db.scalars(query)).fetch_one() + + +@strawberry.type +class CreateFlightMutation: + @strawberry_sqlalchemy_input(models.Flight, all_optional=True) + class FlightInput: + pass + + @strawberry.mutation + async def create_flight(self, info, input_: FlightInput) -> Flight: + model = models.Flight(name=input_.name) + + db = info.context.db + db.add(model) + await db.commit() + + return Flight(model) diff --git a/src/graphql_schema/entities/user.py b/src/graphql_schema/entities/user.py new file mode 100644 index 0000000..17c5dfe --- /dev/null +++ b/src/graphql_schema/entities/user.py @@ -0,0 +1,25 @@ +import strawberry +from sqlalchemy import select +from database.models import User +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type + + +@strawberry_sqlalchemy_type(User, exclude_fields=['password_hashed']) +class UserType: + pass + + +@strawberry.type +class LoginResultType: + logged_user = UserType + access_token: str + refresh_token: str + + +@strawberry.type +class UserQueries: + + @strawberry.field + async def logged_user(root, info) -> UserType: + query = select(User).filter_by(id=info.context.user_id) + return (await info.context.db.scalars(query)).first() diff --git a/src/graphql_schema/mutation.py b/src/graphql_schema/mutation.py new file mode 100644 index 0000000..15cc5d0 --- /dev/null +++ b/src/graphql_schema/mutation.py @@ -0,0 +1,10 @@ +from strawberry.tools import merge_types +from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation +from graphql_schema.entities.flight import CreateFlightMutation + +Mutation = merge_types("Mutation", ( + CreateAircraftMutation, + EditAircraftMutation, + DeleteAircraftMutation, + CreateFlightMutation, +)) diff --git a/src/graphql_schema/query.py b/src/graphql_schema/query.py new file mode 100644 index 0000000..41481ff --- /dev/null +++ b/src/graphql_schema/query.py @@ -0,0 +1,15 @@ +from strawberry.tools import merge_types +from .entities.aircraft import AircraftQueries +from .entities.copilot import CopilotQueries +from .entities.flight import FlightQueries +from .entities.user import UserQueries + +# https://github.com/strawberry-graphql/examples/blob/main/fastapi-sqlalchemy/api/schema.py + + +Query = merge_types('Query', ( + AircraftQueries, + FlightQueries, + CopilotQueries, + UserQueries +)) diff --git a/src/graphql_schema/schema.py b/src/graphql_schema/schema.py new file mode 100644 index 0000000..cd60c6d --- /dev/null +++ b/src/graphql_schema/schema.py @@ -0,0 +1,42 @@ +import dataclasses +import strawberry +from fastapi_jwt import JwtAuthorizationCredentials +from fastapi_jwt.jwt import JwtAccessBearerCookie +from sqlalchemy.ext.asyncio import AsyncSession +from strawberry.extensions import SchemaExtension +from strawberry.fastapi import BaseContext +from .mutation import Mutation +from .query import Query + + +# Toto se da kdyztak pouzit jako extension do Schema +# class SQLAlchemySession(Extension): +# def on_request_start(self): +# session = async_session() +# print(self.execution_context.context) +# self.execution_context.context["db"] = session +# +# async def on_request_end(self): +# await self.execution_context.context["db"].close() + +class LoggingExtension(SchemaExtension): + def on_request_start(self): + print("request start") + + async def on_request_end(self): + print("request end") + + +@dataclasses.dataclass +class GraphQLContext(BaseContext): + db: AsyncSession + user_id: int + jwt_auth_credentials: JwtAuthorizationCredentials + jwt: JwtAccessBearerCookie + + +schema = strawberry.Schema( + query=Query, + mutation=Mutation, + extensions=[LoggingExtension] +) diff --git a/src/graphql_schema/sqlalchemy_to_strawberry_type.py b/src/graphql_schema/sqlalchemy_to_strawberry_type.py new file mode 100644 index 0000000..9974da9 --- /dev/null +++ b/src/graphql_schema/sqlalchemy_to_strawberry_type.py @@ -0,0 +1,54 @@ +import typing +from typing import List, Optional +import strawberry +from sqlalchemy import inspect +from database.models import BaseModel + + +def get_annotations_for_scalars(model: BaseModel, exclude_fields=None, force_optional: bool = False): + if exclude_fields is None: + exclude_fields = [] + + annotations_ = {} + for name, column in inspect(model).columns.items(): + is_optional = column.nullable or force_optional + if name in exclude_fields: + continue + annotations_[name] = column.type.python_type if not is_optional else typing.Optional[column.type.python_type] + + return annotations_ + + +def strawberry_sqlalchemy_type(model, exclude_fields: Optional[typing.Union[List, typing.Tuple]] = None): + if exclude_fields is None: + exclude_fields = [] + + def from_sqlalchemy_model(model: BaseModel): + return model + + def wrapper(cls): + cls.__annotations__.update(get_annotations_for_scalars(model, exclude_fields=exclude_fields + ["deleted"])) + cls.from_sqlalchemy_model = from_sqlalchemy_model + return strawberry.type(cls) + + return wrapper + + +def strawberry_sqlalchemy_input( + model, + exclude_fields: Optional[typing.Union[List, typing.Tuple]] = None, + all_optional: bool = False): + if exclude_fields is None: + exclude_fields = [] + + ignored_fields = ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] + + def wrapper(cls): + cls.__annotations__.update(get_annotations_for_scalars( + model, + exclude_fields=exclude_fields + ignored_fields, + force_optional=all_optional + )) + return strawberry.input(cls) + + return wrapper diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..635a0d1 --- /dev/null +++ b/src/main.py @@ -0,0 +1,127 @@ +from datetime import timedelta +from fastapi import FastAPI, APIRouter, Depends, Security +from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.middleware.cors import CORSMiddleware +from starlette.responses import RedirectResponse, Response +from strawberry.fastapi import GraphQLRouter +from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG +from dependencies.db import db_session +from endpoints.init_data import InitDataEndpoint +from endpoints.flights import FlightsEndpoint +from endpoints.login import LoginEndpoint, LoginInput, MeEndpoint +from endpoints.registration import RegistrationInput, RegistrationEndpoint +from graphql_schema.schema import schema, GraphQLContext + + +class App: + api_router = APIRouter(dependencies=[]) + access_security = JwtAccessBearerCookie( + secret_key=APP_SECRET_KEY, + auto_error=False, + access_expires_delta=timedelta(hours=1) + ) + refresh_security = JwtRefreshBearerCookie( + secret_key=APP_SECRET_KEY, + auto_error=True + ) + + def create_app(self): + app = FastAPI() + + self.setup_exception_handlers(app) + self.setup_middleware(app) + self.setup_routes(app) + + return app + + @staticmethod + def setup_exception_handlers(app: FastAPI): + pass + + @staticmethod + def setup_middleware(app: FastAPI): + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + def setup_graphql_endpoint(self, app: FastAPI): + if APP_DEBUG: + @self.api_router.get("/graphql/autologin") + async def autologin(): + access_token = self.access_security.create_access_token(subject={"id": 8, "name": "Franta Vomacka"}) + + response = RedirectResponse(url="/graphql") + self.access_security.set_access_cookie(response, access_token) + + return response + + def setup_graphql_context( + credentials: JwtAuthorizationCredentials = Security(self.access_security), + db: AsyncSession = Depends(db_session), + ): + + return GraphQLContext( + jwt_auth_credentials=credentials, + user_id=credentials['id'], + db=db, + jwt=self.access_security + ) + + graphql_app = GraphQLRouter( + schema, + graphiql=GRAPHIQL, + debug=APP_DEBUG, + context_getter=setup_graphql_context + ) + app.include_router(graphql_app, prefix="/graphql") + + def setup_routes(self, app: FastAPI): + # protected endpoints + @self.api_router.get("/me") + async def me( + db: AsyncSession = Depends(db_session), + credentials: JwtAuthorizationCredentials = Security(self.access_security) + ): + return await MeEndpoint(db, credentials).on_get() + + # @app.post("/refresh") + # def refresh( + # credentials: JwtAuthorizationCredentials = Security(refresh_security) + # ): + # # Update access/refresh tokens pair + # # We can customize expires_delta when creating + # access_token = access_security.create_access_token(subject=credentials.subject) + # refresh_token = refresh_security.create_refresh_token(subject=credentials.subject, + # expires_delta=timedelta(days=2)) + # + # return {"access_token": access_token, "refresh_token": refresh_token} + + self.setup_graphql_endpoint(app) + + # public endpoints + + @self.api_router.post("/login") + async def login(resp: Response, user: LoginInput, db: AsyncSession = Depends(db_session)): + return await LoginEndpoint(db, self.access_security, self.refresh_security).on_post(user, resp) + + @self.api_router.post("/registration", status_code=201) + async def registration(user: RegistrationInput, db: AsyncSession = Depends(db_session)): + return await RegistrationEndpoint(db).on_post(user) + + # testing endpoint + + @self.api_router.get("/init-data") + async def init_data(db: AsyncSession = Depends(db_session)): + return await InitDataEndpoint(db).on_get() + + @self.api_router.get("/flights", status_code=200) + async def flights(db: AsyncSession = Depends(db_session)): + return await FlightsEndpoint(db).resolve() + + # musi byt na konci + app.include_router(self.api_router) diff --git a/uploads/.gitkeep b/uploads/.gitkeep new file mode 100644 index 0000000..e69de29