diff --git a/alembic/versions/20230606-041607_flight_edit_66e39f31b1d2.py b/alembic/versions/20230606-041607_flight_edit_66e39f31b1d2.py new file mode 100644 index 0000000..8736df0 --- /dev/null +++ b/alembic/versions/20230606-041607_flight_edit_66e39f31b1d2.py @@ -0,0 +1,32 @@ +"""flight edit + +Revision ID: 66e39f31b1d2 +Revises: 431adcd37681 +Create Date: 2023-06-06 04:16:07.568446 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '66e39f31b1d2' +down_revision = '431adcd37681' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('takeoff_datetime', sa.DateTime(), nullable=False)) + op.add_column('flight', sa.Column('landing_datetime', sa.DateTime(), nullable=False)) + op.drop_column('flight', 'duration_total') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('duration_total', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False)) + op.drop_column('flight', 'landing_datetime') + op.drop_column('flight', 'takeoff_datetime') + # ### end Alembic commands ### diff --git a/alembic/versions/20230606-043510_flight_edit_5781b1432475.py b/alembic/versions/20230606-043510_flight_edit_5781b1432475.py new file mode 100644 index 0000000..5bae518 --- /dev/null +++ b/alembic/versions/20230606-043510_flight_edit_5781b1432475.py @@ -0,0 +1,32 @@ +"""flight edit + +Revision ID: 5781b1432475 +Revises: 66e39f31b1d2 +Create Date: 2023-06-06 04:35:10.408586 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5781b1432475' +down_revision = '66e39f31b1d2' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('solo', sa.Boolean(), nullable=False)) + op.add_column('flight', sa.Column('with_instructor', sa.Boolean(), nullable=False)) + op.add_column('flight', sa.Column('landings', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('flight', 'landings') + op.drop_column('flight', 'with_instructor') + op.drop_column('flight', 'solo') + # ### end Alembic commands ### diff --git a/alembic/versions/20230606-050023_add_soft_delete_fd5fc5c1e2f6.py b/alembic/versions/20230606-050023_add_soft_delete_fd5fc5c1e2f6.py new file mode 100644 index 0000000..7236d5c --- /dev/null +++ b/alembic/versions/20230606-050023_add_soft_delete_fd5fc5c1e2f6.py @@ -0,0 +1,34 @@ +"""add soft delete + +Revision ID: fd5fc5c1e2f6 +Revises: 5781b1432475 +Create Date: 2023-06-06 05:00:23.250817 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'fd5fc5c1e2f6' +down_revision = '5781b1432475' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('copilot', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('flight', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('metar', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('organization', 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('organization', 'deleted') + op.drop_column('metar', 'deleted') + op.drop_column('flight', 'deleted') + op.drop_column('copilot', 'deleted') + # ### end Alembic commands ### diff --git a/alembic/versions/20230606-050051_add_soft_delete_559795b043f3.py b/alembic/versions/20230606-050051_add_soft_delete_559795b043f3.py new file mode 100644 index 0000000..e931534 --- /dev/null +++ b/alembic/versions/20230606-050051_add_soft_delete_559795b043f3.py @@ -0,0 +1,32 @@ +"""add soft delete + +Revision ID: 559795b043f3 +Revises: fd5fc5c1e2f6 +Create Date: 2023-06-06 05:00:51.512887 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '559795b043f3' +down_revision = 'fd5fc5c1e2f6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('airport', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('point_of_interest', sa.Column('deleted', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('point_of_interest_type', 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('point_of_interest_type', 'deleted') + op.drop_column('point_of_interest', 'deleted') + op.drop_column('airport', 'deleted') + # ### end Alembic commands ### diff --git a/src/database/models.py b/src/database/models.py index af91180..f0b0e28 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,3 +1,4 @@ +from __future__ import annotations import datetime from typing import Set from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select @@ -17,6 +18,10 @@ class BaseModel: if c.name not in self.excluded_columns_in_dict } + @classmethod + async def get_one(cls, db_session: AsyncSession, id: int): + return (await db_session.scalars(select(cls).filter_by(id=id))).one() + @classmethod async def create(cls, db_session: AsyncSession, data: dict): model = cls(**data) @@ -26,8 +31,12 @@ class BaseModel: 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() + async def update(cls, db_session: AsyncSession, data: dict, obj: BaseModel = None, id: int = None): + if not obj and not id: + raise ValueError("Provide either obj or id!") + + if not obj: + obj = await cls.get_one(db_session, id) for key, value in data.items(): if getattr(obj, key) != value: setattr(obj, key, value) @@ -56,6 +65,7 @@ class Airport(BaseModel): 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()) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') metars: Mapped['Metar'] = relationship(back_populates="airport") @@ -68,6 +78,7 @@ class PointOfInterestType(BaseModel): 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') created_by: Mapped['User'] = relationship() @@ -82,6 +93,7 @@ class PointOfInterest(BaseModel): 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') type: Mapped[PointOfInterestType] = relationship() created_by: Mapped['User'] = relationship() @@ -145,6 +157,7 @@ class Organization(BaseModel): 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()) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') users: Mapped[Set['User']] = relationship(back_populates='organizations', secondary=user_is_in_organization) created_by: Mapped['User'] = relationship() @@ -168,14 +181,21 @@ class Flight(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) + takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) + landing_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) + duration_pic: Mapped[int] = mapped_column(Integer, 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) + + solo: Mapped[bool] = mapped_column(Boolean, default=True) + with_instructor: Mapped[bool] = mapped_column(Boolean, default=False) + landings: Mapped[int] = mapped_column(Integer, default=1) + 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) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') takeoff_airport: Mapped['Airport'] = relationship(foreign_keys=[takeoff_airport_id]) landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id]) @@ -184,6 +204,7 @@ class Flight(BaseModel): 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) @@ -194,6 +215,7 @@ class Copilot(BaseModel): 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()) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') flights: Mapped[Set['Flight']] = relationship(back_populates="copilot") created_by: Mapped['User'] = relationship() @@ -206,6 +228,7 @@ class Metar(BaseModel): airport_id: Mapped[int] = mapped_column(Integer, ForeignKey('airport.id')) metar: Mapped[str] = mapped_column(Text, nullable=False) issued_at: Mapped[datetime] = mapped_column(DateTime) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') airport: Mapped['Airport'] = relationship(back_populates="metars") diff --git a/src/graphql_schema/dataloaders/airport.py b/src/graphql_schema/dataloaders/airport.py new file mode 100644 index 0000000..052279e --- /dev/null +++ b/src/graphql_schema/dataloaders/airport.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 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.get(id_) for id_ in ids] + + +airport_dataloader = DataLoader(load_fn=load) diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 1864bbe..1590dd5 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -1,4 +1,3 @@ -import os import uuid from typing import List, Optional import strawberry @@ -6,6 +5,9 @@ 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 +from upload_utils import handle_img_upload, delete_file + +AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/" @strawberry_sqlalchemy_type(models.Aircraft) @@ -44,20 +46,6 @@ class AircraftQueries: 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']) @@ -68,21 +56,14 @@ class CreateAircraftMutation: async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: # TODO: kontrola organizace - filename = None + input_data = input.to_dict() 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) + input_data['photo_filename'] = await handle_img_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH) 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, + **input_data, created_by_id=info.context.user_id, ) ) @@ -98,17 +79,16 @@ class EditAircraftMutation: 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, - ) - ) + + 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_img_upload(input.photo, AIRCRAFT_UPLOAD_DEST_PATH) + + return await models.Aircraft.update(info.context.db, obj=aircraft, data=update_data) @strawberry.type @@ -118,4 +98,4 @@ class DeleteAircraftMutation: 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)) + return await models.Aircraft.update(info.context.db, id=id, data=dict(deleted=True)) diff --git a/src/graphql_schema/entities/airport.py b/src/graphql_schema/entities/airport.py new file mode 100644 index 0000000..fd66486 --- /dev/null +++ b/src/graphql_schema/entities/airport.py @@ -0,0 +1,38 @@ +from typing import List +import strawberry +from sqlalchemy import select +from database import models +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)) + ) + + +@strawberry.type +class AirportQueries: + + @strawberry.field + async def airports(root, info) -> List[Airport]: + query = ( + get_base_query(info.context.user_id) + .order_by(models.Airport.id.desc()) + ) + + return (await info.context.db.scalars(query)).all() + + @strawberry.field + async def airport(root, info, id: int) -> Airport: + query = ( + get_base_query(info.context.user_id) + .filter(models.Airport.id == id) + ) + return (await info.context.db.scalars(query)).one() diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index 70aeb74..5459a67 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -1,10 +1,14 @@ from typing import List, Optional import strawberry from sqlalchemy import select +from strawberry.file_uploads import Upload + from database import models from graphql_schema.dataloaders import copilots_dataloader from graphql_schema.dataloaders.aircraft import aircraft_dataloader +from graphql_schema.dataloaders.airport import airport_dataloader from graphql_schema.entities.aircraft import Aircraft +from graphql_schema.entities.airport import Airport from graphql_schema.entities.copilot import CopilotType from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input @@ -13,6 +17,11 @@ from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_t @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) @@ -22,6 +31,17 @@ class Flight: copilot: Optional[CopilotType] = strawberry.field(resolver=load_copilot) 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) + # take_off_airport = Airport + + +def get_base_query(user_id: int): + return ( + select(models.Flight) + .filter(models.Flight.created_by_id == user_id) + .order_by(models.Flight.id.desc()) + ) @strawberry.type @@ -32,36 +52,31 @@ class FlightQueries: @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 - ) + query = get_base_query(info.context.user_id).order_by(models.Flight.id.desc()) return (await info.context.db.scalars(query)).all() @strawberry.field async def flight(root, info, id: int) -> Flight: query = ( - select(models.Flight) + get_base_query(info.context.user_id) .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: + @strawberry_sqlalchemy_input(models.Flight, exclude_fields=["id"]) + class CreateFlightInput: + # photos: Optional[List[Upload]] pass @strawberry.mutation - async def create_flight(self, info, input_: FlightInput) -> Flight: - model = models.Flight(name=input_.name) + async def create_flight(self, info, input: CreateFlightInput) -> Flight: + input_data = input.to_dict() - db = info.context.db - db.add(model) - await db.commit() - - return Flight(model) + return await models.Flight.create(info.context.db, data={ + **input_data, + "created_by_id": info.context.user_id + }) diff --git a/src/graphql_schema/query.py b/src/graphql_schema/query.py index 41481ff..3f0d954 100644 --- a/src/graphql_schema/query.py +++ b/src/graphql_schema/query.py @@ -1,5 +1,6 @@ from strawberry.tools import merge_types from .entities.aircraft import AircraftQueries +from .entities.airport import AirportQueries from .entities.copilot import CopilotQueries from .entities.flight import FlightQueries from .entities.user import UserQueries @@ -9,6 +10,7 @@ from .entities.user import UserQueries Query = merge_types('Query', ( AircraftQueries, + AirportQueries, FlightQueries, CopilotQueries, UserQueries diff --git a/src/graphql_schema/sqlalchemy_to_strawberry_type.py b/src/graphql_schema/sqlalchemy_to_strawberry_type.py index 9974da9..4a40674 100644 --- a/src/graphql_schema/sqlalchemy_to_strawberry_type.py +++ b/src/graphql_schema/sqlalchemy_to_strawberry_type.py @@ -1,20 +1,26 @@ import typing from typing import List, Optional import strawberry -from sqlalchemy import inspect +import sqlalchemy +from sqlalchemy import Column from database.models import BaseModel +def get_columns_from_model(model: BaseModel, exclude_fields: List[str]) -> List[typing.Tuple[str, Column]]: + return [(name, column) for name, column in sqlalchemy.inspect(model).columns.items() if name not in exclude_fields] + + 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(): + for name, column in get_columns_from_model(model, exclude_fields): 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] + try: + annotations_[name] = column.type.python_type if not is_optional else typing.Optional[column.type.python_type] + except NotImplementedError as e: + print(f"Neimplementovano: {e}, {name=}") return annotations_ @@ -41,14 +47,18 @@ def strawberry_sqlalchemy_input( if exclude_fields is None: exclude_fields = [] - ignored_fields = ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] + ignored_fields = exclude_fields + ["created_at", "created_by_id", "updated_by_id", "updated_at", "deleted"] + + def to_dict(self): + return {name: getattr(self, name) for name, _ in get_columns_from_model(model, ignored_fields)} def wrapper(cls): cls.__annotations__.update(get_annotations_for_scalars( model, - exclude_fields=exclude_fields + ignored_fields, + exclude_fields=ignored_fields, force_optional=all_optional )) + cls.to_dict = to_dict return strawberry.input(cls) return wrapper diff --git a/src/upload_utils.py b/src/upload_utils.py new file mode 100644 index 0000000..1a1f9df --- /dev/null +++ b/src/upload_utils.py @@ -0,0 +1,23 @@ +import os +import uuid + +from strawberry.file_uploads import Upload + + +def check_directories(path: str): + if not os.path.isdir(path): + os.makedirs(path) + + +async def handle_img_upload(file: Upload, path: str): + check_directories(path) + + filename = f"{uuid.uuid4()}-{file.filename}" + + content = await file.read() + image = open(path + "/" + filename, "wb") + image.write(content) + image.close() + +def delete_file(path: str, silent: bool = False): + os.remove(path) \ No newline at end of file