From 9314d31186e69185130d159be2fab651fcde843d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 21 Aug 2023 22:45:33 +0200 Subject: [PATCH] Vetsi sada zmen --- Dockerfile | 3 +- ...4602_add_seats_to_aircraft_71d100e65f04.py | 28 ++++ ...t_copilot_m_n_relationship_434066bcb25d.py | 38 +++++ ...ename_columns_with_weather_5c43394aa8e0.py | 42 +++++ docker-compose.yml | 11 +- src/config.py | 9 +- src/database/models.py | 32 ++-- src/decorators/endpoints.py | 22 ++- src/endpoints/base.py | 3 +- src/graphql_schema/dataloaders/__init__.py | 5 - src/graphql_schema/dataloaders/copilots.py | 18 +- src/graphql_schema/dataloaders/flight.py | 29 ++-- src/graphql_schema/entities/aircraft.py | 14 +- src/graphql_schema/entities/copilot.py | 9 +- src/graphql_schema/entities/flight.py | 157 ++++++------------ src/graphql_schema/entities/helpers/flight.py | 56 ++++++- src/graphql_schema/entities/photo.py | 12 +- src/graphql_schema/entities/poi.py | 8 +- src/graphql_schema/entities/user.py | 25 ++- .../sqlalchemy_to_strawberry_type.py | 19 ++- src/main.py | 8 +- src/upload_utils.py | 5 +- 22 files changed, 352 insertions(+), 201 deletions(-) create mode 100644 alembic/versions/20230816-064602_add_seats_to_aircraft_71d100e65f04.py create mode 100644 alembic/versions/20230816-070350_make_flight_copilot_m_n_relationship_434066bcb25d.py create mode 100644 alembic/versions/20230816-163136_rename_columns_with_weather_5c43394aa8e0.py diff --git a/Dockerfile b/Dockerfile index 085c26a..a8a7883 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,6 @@ 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) @@ -24,4 +23,4 @@ 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 +CMD ["uvicorn", "src.asgi:app", "--reload", "--reload-dir=/app", "--port=8000", "--host=0.0.0.0", "--log-level=debug"] \ No newline at end of file diff --git a/alembic/versions/20230816-064602_add_seats_to_aircraft_71d100e65f04.py b/alembic/versions/20230816-064602_add_seats_to_aircraft_71d100e65f04.py new file mode 100644 index 0000000..09c8ca3 --- /dev/null +++ b/alembic/versions/20230816-064602_add_seats_to_aircraft_71d100e65f04.py @@ -0,0 +1,28 @@ +"""add seats to aircraft + +Revision ID: 71d100e65f04 +Revises: dd48d8dab743 +Create Date: 2023-08-16 06:46:02.618843 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '71d100e65f04' +down_revision = 'dd48d8dab743' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('aircraft', sa.Column('seats', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('aircraft', 'seats') + # ### end Alembic commands ### diff --git a/alembic/versions/20230816-070350_make_flight_copilot_m_n_relationship_434066bcb25d.py b/alembic/versions/20230816-070350_make_flight_copilot_m_n_relationship_434066bcb25d.py new file mode 100644 index 0000000..ef0d42e --- /dev/null +++ b/alembic/versions/20230816-070350_make_flight_copilot_m_n_relationship_434066bcb25d.py @@ -0,0 +1,38 @@ +"""make flight-copilot M:N relationship + +Revision ID: 434066bcb25d +Revises: 71d100e65f04 +Create Date: 2023-08-16 07:03:50.599364 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '434066bcb25d' +down_revision = '71d100e65f04' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('flight_has_copilot', + sa.Column('flight_id', sa.Integer(), nullable=False), + sa.Column('copilot_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['copilot_id'], ['copilot.id'], ), + sa.ForeignKeyConstraint(['flight_id'], ['flight.id'], ), + sa.PrimaryKeyConstraint('flight_id', 'copilot_id') + ) + op.drop_constraint('flight_ibfk_2', 'flight', type_='foreignkey') + op.drop_column('flight', 'copilot_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('copilot_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.create_foreign_key('flight_ibfk_2', 'flight', 'copilot', ['copilot_id'], ['id']) + op.drop_table('flight_has_copilot') + # ### end Alembic commands ### diff --git a/alembic/versions/20230816-163136_rename_columns_with_weather_5c43394aa8e0.py b/alembic/versions/20230816-163136_rename_columns_with_weather_5c43394aa8e0.py new file mode 100644 index 0000000..00ce1da --- /dev/null +++ b/alembic/versions/20230816-163136_rename_columns_with_weather_5c43394aa8e0.py @@ -0,0 +1,42 @@ +"""rename columns with weather + +Revision ID: 5c43394aa8e0 +Revises: 434066bcb25d +Create Date: 2023-08-16 16:31:36.204608 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '5c43394aa8e0' +down_revision = '434066bcb25d' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('takeoff_weather_info_id', sa.Integer(), nullable=True)) + op.add_column('flight', sa.Column('landing_weather_info_id', sa.Integer(), nullable=True)) + op.drop_constraint('flight_ibfk_6', 'flight', type_='foreignkey') + op.drop_constraint('flight_ibfk_7', 'flight', type_='foreignkey') + op.create_foreign_key(None, 'flight', 'weather_info', ['takeoff_weather_info_id'], ['id']) + op.create_foreign_key(None, 'flight', 'weather_info', ['landing_weather_info_id'], ['id']) + op.drop_column('flight', 'weather_info_landing_id') + op.drop_column('flight', 'weather_info_takeoff_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('weather_info_takeoff_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.add_column('flight', sa.Column('weather_info_landing_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True)) + op.drop_constraint(None, 'flight', type_='foreignkey') + op.drop_constraint(None, 'flight', type_='foreignkey') + op.create_foreign_key('flight_ibfk_7', 'flight', 'weather_info', ['weather_info_takeoff_id'], ['id']) + op.create_foreign_key('flight_ibfk_6', 'flight', 'weather_info', ['weather_info_landing_id'], ['id']) + op.drop_column('flight', 'landing_weather_info_id') + op.drop_column('flight', 'takeoff_weather_info_id') + # ### end Alembic commands ### diff --git a/docker-compose.yml b/docker-compose.yml index a29e8ff..53da441 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - .:/app environment: TZ: "Europe/Prague" + API_URL: "http://localhost:8000" + ALLOW_CORS_ORIGINS: "http://localhost:9000 http://localhost:9001" scrapy: build: @@ -32,14 +34,5 @@ services: - db-data:/var/lib/mysql:rw - ../crawlers:/app - nginx: - image: nginx:alpine - restart: always - volumes: - - ./config/nginx.dev.yml:/etc/nginx/conf.d/app-dev.conf - - ./uploads:/app/uploads - ports: - - "8999:8999" - volumes: db-data: { } \ No newline at end of file diff --git a/src/config.py b/src/config.py index 20691dc..078874e 100644 --- a/src/config.py +++ b/src/config.py @@ -1,3 +1,10 @@ +import os + APP_DEBUG = True GRAPHIQL = True -APP_SECRET_KEY = "test" +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() + +if not APP_SECRET_KEY: + raise ValueError("Missing APP_SECRET_KEY!") diff --git a/src/database/models.py b/src/database/models.py index a31ad4f..ce540bb 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -8,8 +8,6 @@ from sqlalchemy.ext.asyncio import AsyncSession @as_declarative() class BaseModel: - # __mapper_args__ = {"eager_defaults": True} - excluded_columns_in_dict = tuple() def as_dict(self): @@ -52,13 +50,20 @@ user_is_in_organization = Table( Column("organization_id", Integer, ForeignKey("organization.id"), primary_key=True) ) +flight_has_copilot = Table( + "flight_has_copilot", + BaseModel.metadata, + Column("flight_id", ForeignKey("flight.id"), primary_key=True), + Column("copilot_id", ForeignKey("copilot.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) + 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) elevation: Mapped[int] = mapped_column(Integer, nullable=True) @@ -132,6 +137,7 @@ class Aircraft(BaseModel): 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, server_default="") + seats: Mapped[str] = mapped_column(Integer, nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") organization_id: Mapped[int] = mapped_column(Integer, ForeignKey('organization.id'), nullable=True) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=False) @@ -213,9 +219,8 @@ class Flight(BaseModel): duration_pic: Mapped[int] = mapped_column(Integer, nullable=True) gpx_track_filename: Mapped[str] = mapped_column(String(128), nullable=True) aircraft_id: Mapped[int] = mapped_column(Integer, ForeignKey('aircraft.id')) - copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True) - weather_info_takeoff_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True) - weather_info_landing_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True) + takeoff_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True) + landing_weather_info_id: Mapped[int] = mapped_column(Integer, ForeignKey('weather_info.id'), nullable=True) landings: Mapped[int] = mapped_column(Integer, default=1) is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) @@ -224,13 +229,12 @@ class Flight(BaseModel): takeoff_airport: Mapped['Airport'] = relationship(foreign_keys=[takeoff_airport_id]) landing_airport: Mapped['Airport'] = relationship(foreign_keys=[landing_airport_id]) - weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[weather_info_landing_id]) - weather_info_takeoff: Mapped[WeatherInfo] = relationship(foreign_keys=[weather_info_takeoff_id]) + weather_info_landing: Mapped[WeatherInfo] = relationship(foreign_keys=[landing_weather_info_id]) + weather_info_takeoff: Mapped[WeatherInfo] = relationship(foreign_keys=[takeoff_weather_info_id]) track: Mapped['FlightTrack'] = relationship() - copilot: Mapped['Copilot'] = relationship(back_populates="flights") + copilots: Mapped[List['Copilot']] = relationship(secondary=flight_has_copilot) aircraft: Mapped['Aircraft'] = relationship(back_populates="flights") photos: Mapped[List['Photo']] = relationship(foreign_keys=[Photo.flight_id]) - user: Mapped['User'] = relationship(back_populates="flights") created_by: Mapped['User'] = relationship() @@ -243,7 +247,7 @@ class Copilot(BaseModel): 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") + flights: Mapped[Set['Flight']] = relationship(secondary=flight_has_copilot) created_by: Mapped['User'] = relationship() @@ -287,6 +291,6 @@ class User(BaseModel): 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) + licences: Mapped[Set['License']] = relationship() + flights: Mapped[Set['Flight']] = relationship() + organizations: Mapped[Set['Organization']] = relationship(secondary=user_is_in_organization) diff --git a/src/decorators/endpoints.py b/src/decorators/endpoints.py index f2036c7..48ceb21 100644 --- a/src/decorators/endpoints.py +++ b/src/decorators/endpoints.py @@ -7,13 +7,19 @@ def public_endpoint(func): pass -def authenticated_user_only(func): - @wraps(func) - async def decorator(*args, **kwargs): - if 'info' in kwargs: - if not kwargs['info'].context.user_id: - raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized") +def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_unauthorized=None): + def wrapper(func): + @wraps(func) + async def decorator(*args, **kwargs): + if 'info' in kwargs: + if not kwargs['info'].context.user_id: + if raise_when_unauthorized: + raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized") + else: + return return_value_unauthorized - return await func(*args, **kwargs) + return await func(*args, **kwargs) - return decorator + return decorator + + return wrapper diff --git a/src/endpoints/base.py b/src/endpoints/base.py index b5553e5..034652e 100644 --- a/src/endpoints/base.py +++ b/src/endpoints/base.py @@ -1,9 +1,10 @@ from fastapi_jwt.jwt import JwtAccess, JwtRefresh -class BaseEndpoint: +class BaseEndpoint(object): def __init__(self, db): self.db = db + super().__init__() class AuthEndpoint: diff --git a/src/graphql_schema/dataloaders/__init__.py b/src/graphql_schema/dataloaders/__init__.py index 34f4f31..e69de29 100644 --- a/src/graphql_schema/dataloaders/__init__.py +++ b/src/graphql_schema/dataloaders/__init__.py @@ -1,5 +0,0 @@ -__all__ = [ - 'copilots_dataloader' -] - -from graphql_schema.dataloaders.copilots import copilots_dataloader diff --git a/src/graphql_schema/dataloaders/copilots.py b/src/graphql_schema/dataloaders/copilots.py index ef4f1f4..9f9a62f 100644 --- a/src/graphql_schema/dataloaders/copilots.py +++ b/src/graphql_schema/dataloaders/copilots.py @@ -1,16 +1,24 @@ +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 +from database.models import Copilot, Flight 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 = (await session.execute( + select(Copilot, Flight.id) + .join(Copilot.flights) + .filter(Flight.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_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] -copilots_dataloader = DataLoader(load_fn=load, cache=False) +flight_copilots_dataloader = DataLoader(load_fn=load, cache=False) diff --git a/src/graphql_schema/dataloaders/flight.py b/src/graphql_schema/dataloaders/flight.py index ba799c4..87c7071 100644 --- a/src/graphql_schema/dataloaders/flight.py +++ b/src/graphql_schema/dataloaders/flight.py @@ -1,29 +1,38 @@ from collections import defaultdict -from typing import List +from typing import List, Optional from sqlalchemy import select from strawberry.dataloader import DataLoader from database import async_session -from database.models import Flight +from database.models import Flight, Copilot class FlightsLoader: - def __init__(self, relationship_column: str): + 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) - .filter(getattr(Flight, self.relationship_column).in_(ids)) + select(Flight, rel_column) + .filter(rel_column.in_(ids)) ) - data = (await session.scalars(query)).all() + + for table in self.extra_join: + query = query.join(table) + + data = (await session.execute(query)).all() result_data = defaultdict(list) - for poi in data: - result_data[getattr(poi, self.relationship_column)].append(poi) + 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").load, cache=False) -flights_by_aircraft_dataloader = DataLoader(load_fn=FlightsLoader("aircraft_id").load, cache=False) +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) diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 25b56d5..7824ea5 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -37,8 +37,8 @@ def get_base_query(user_id: int): @strawberry.type class AircraftQueries: - @strawberry.field - @authenticated_user_only + @strawberry.field() + @authenticated_user_only() async def aircrafts(root, info) -> List[Aircraft]: query = ( get_base_query(info.context.user_id) @@ -47,8 +47,8 @@ class AircraftQueries: return (await info.context.db.scalars(query)).all() - @strawberry.field - @authenticated_user_only + @strawberry.field() + @authenticated_user_only() async def aircraft(root, info, id: int) -> Aircraft: query = ( get_base_query(info.context.user_id) @@ -64,7 +64,7 @@ class CreateAircraftMutation: photo: Optional[Upload] @strawberry.mutation - @authenticated_user_only + @authenticated_user_only() async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: # TODO: kontrola organizace @@ -88,7 +88,7 @@ class EditAircraftMutation: photo: Optional[Upload] @strawberry.mutation - @authenticated_user_only + @authenticated_user_only() async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft: # TODO: kontrola organizace # TODO: kontrola opravneni na akci @@ -108,7 +108,7 @@ class EditAircraftMutation: class DeleteAircraftMutation: @strawberry.mutation - @authenticated_user_only + @authenticated_user_only() async def delete_aircraft(self, info, id: int) -> Aircraft: # TODO: kontrola opravneni na akci diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index 6d9e2c9..24275ed 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -2,6 +2,7 @@ from typing import List, Annotated, TYPE_CHECKING import strawberry from sqlalchemy import select 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 @@ -28,13 +29,15 @@ def get_base_query(user_id: int): @strawberry.type class CopilotQueries: - @strawberry.field + @strawberry.field() + @authenticated_user_only() async def copilots(root, info) -> List[Copilot]: return (await info.context.db.scalars( get_base_query(info.context.user_id) )).all() - @strawberry.field + @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) @@ -50,6 +53,7 @@ class CreateCopilotMutation: pass @strawberry.mutation + @authenticated_user_only() async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot: input_data = input.to_dict() return await models.Copilot.create( @@ -69,6 +73,7 @@ class EditCopilotMutation: pass @strawberry.mutation + @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) diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index 8ad0912..b2b8fc0 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -1,27 +1,27 @@ from datetime import timedelta -from functools import wraps from typing import List, Optional, Annotated, TYPE_CHECKING, Tuple import strawberry from fastapi import HTTPException -from sqlalchemy import select +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 decorators.error_logging import error_logging -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.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 handle_file_upload, file_exists, delete_file -from .helpers.flight import handle_aircraft_save, handle_track_edit, handle_copilot_edit, handle_weather_info -from ..dataloaders.weather import airport_weather_info_loader +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 from ..types import ComboboxInput if TYPE_CHECKING: @@ -55,9 +55,6 @@ 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) - async def load_photos(root): return await photos_dataloader.load(root.id) @@ -65,10 +62,10 @@ class Flight: return await cover_photo_loader.load(root.id) async def load_takeoff_weather_info(root): - return await airport_weather_info_loader.load(root.weather_info_takeoff_id) + 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.weather_info_landing_id) + return await airport_weather_info_loader.load(root.landing_weather_info_id) def duration_min_calculated(root): if root.duration_total: @@ -84,10 +81,14 @@ class Flight: if not root.gpx_track_filename: return None - return f"http://localhost:8000/uploads/tracks/{root.gpx_track_filename}" + 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) - copilot: Optional[Annotated["Copilot", strawberry.lazy(".copilot")]] = strawberry.field(resolver=load_copilot) + 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) @@ -110,7 +111,7 @@ def get_base_query(user_id: Optional[int], username: Optional[str] = None, is_au query = query.filter(models.Flight.created_by_id == user_id) if username: - query = query.filter(models.Flight.created_by.public_username == 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)) @@ -121,10 +122,10 @@ def get_base_query(user_id: Optional[int], username: Optional[str] = None, is_au @strawberry.type class FlightQueries: - @strawberry.field + @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) + raise HTTPException(HTTP_401_UNAUTHORIZED, f"user_id={info.context.user_id}, {username=}") query = ( get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) @@ -132,7 +133,7 @@ class FlightQueries: ) return (await info.context.db.scalars(query)).all() - @strawberry.field + @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: @@ -141,26 +142,10 @@ class FlightQueries: query = ( get_base_query(user_id=info.context.user_id, username=username, is_auth=bool(info.context.user_id)) .filter(models.Flight.id == id) - .filter(models.Flight.deleted.is_(False)) ) return (await info.context.db.scalars(query)).one() -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 - - @strawberry.type class CreateFlightMutation: @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ @@ -173,24 +158,20 @@ class CreateFlightMutation: takeoff_airport: ComboboxInput @strawberry.mutation - @authenticated_user_only + @authenticated_user_only() async def create_flight(self, info, input: CreateFlightInput) -> Flight: db = info.context.db aircraft_id = await handle_aircraft_save(db, info.context.user_id, input.aircraft) data = input.to_dict() - data['takeoff_datetime'] = data['takeoff_datetime'].astimezone() - data['landing_datetime'] = data['landing_datetime'].astimezone() - takeoff_airport, landing_airport = await get_airports(db, input.takeoff_airport.id, input.landing_airport.id) - - weather_takeoff = await handle_weather_info(db, input.takeoff_datetime, takeoff_airport) - weather_landing = await handle_weather_info(db, input.landing_datetime, landing_airport) + weather_takeoff = await handle_weather_info(db, data['takeoff_datetime'], takeoff_airport) + weather_landing = await handle_weather_info(db, data['landing_datetime'], landing_airport) return await models.Flight.create(db, data={ **data, - "weather_info_takeoff_id": weather_takeoff.id, - "weather_info_landing_id": weather_landing.id, + "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, @@ -198,106 +179,78 @@ class CreateFlightMutation: }) - @strawberry.type class EditFlightMutation: @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ - "id", "aircraft_id", "copilot_id", "deleted", "landing_airport_id", "takeoff_airport_id", - "weather_info_takeoff_id", "weather_info_landing_id", "gpx_track_filename" + "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 + gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! track: Optional[List[ComboboxInput]] = None - copilot: Optional[ComboboxInput] = None + copilots: Optional[List[ComboboxInput]] = None aircraft: Optional[ComboboxInput] = None landing_airport: Optional[ComboboxInput] = None takeoff_airport: Optional[ComboboxInput] = None @strawberry.mutation - @authenticated_user_only + @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) + get_base_query(user_id=user_id, is_auth=bool(user_id)).filter(models.Flight.id == id) )).one() - data = input.to_dict() - - if input.takeoff_datetime: - data['takeoff_datetime'] = data['takeoff_datetime'].astimezone() - if input.landing_datetime: - data['landing_datetime'] = data['landing_datetime'].astimezone() - - if input.gpx_track is not None: - # TODO: poresit validaci uploadovaneho souboru! - path = "/app/uploads/tracks" - - if flight.gpx_track_filename and file_exists(path + "/" + flight.gpx_track_filename): - delete_file(path + "/" + flight.gpx_track_filename) - - data['gpx_track_filename'] = await handle_file_upload(input.gpx_track, path) - - update_takeoff_weather = False - update_landing_weather = False - - if input.takeoff_airport is not None and input.takeoff_airport.id != flight.takeoff_airport_id: - data['takeoff_airport_id'] = input.takeoff_airport.id - update_takeoff_weather = True - - if input.landing_airport is not None and input.landing_airport.id != flight.landing_airport_id: - data['landing_airport_id'] = input.landing_airport.id - update_landing_weather = True - 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, ) - if input.takeoff_datetime is not None and input.takeoff_datetime != flight.takeoff_datetime: - update_takeoff_weather = True + data = input.to_dict() - if input.landing_datetime is not None and input.landing_datetime != flight.landing_datetime: - update_landing_weather = True + if input.gpx_track is not None: + data['gpx_track_filename'] = await handle_upload_gpx(flight, input.gpx_track) - if update_takeoff_weather: - weather = await handle_weather_info(db, input.takeoff_datetime, takeoff_airport) + 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 flight.weather_info_takeoff_id: - # db.delete(flight.weather_info_takeoff) - - data['weather_info_takeoff_id'] = weather.id - - if update_landing_weather: - weather = await handle_weather_info(db, input.landing_datetime, landing_airport) - - # if flight.weather_info_landing_id: - # db.delete(flight.weather_info_landing) - - data['weather_info_landing_id'] = weather.id - - # TODO: ^^ to pocasi smrdi zbytecne duplicitnim kodem, neslo by to nejak sjednotit? + 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) - data['copilot_id'] = await handle_copilot_edit(db, input.copilot, user_id) if input.copilot else None - - flight = await models.Flight.update(db, id=id, data=data) - if input.track is not None: await handle_track_edit(db=db, flight=flight, track=input.track, user_id=user_id) - return flight + copilots = await handle_copilots_edit(db, input.copilots or [], user_id) + await db.execute(delete(flight_has_copilot).filter_by(flight_id=flight.id)) + for copilot_id in copilots: + await db.execute(insert(flight_has_copilot).values(flight_id=flight.id, copilot_id=copilot_id)) + + return await models.Flight.update(db, obj=flight, data=data) @strawberry.type class DeleteFlightMutation: @strawberry.mutation + @authenticated_user_only() async def delete_flight(self, info, id: int) -> Flight: flight = ( (await info.context.db.scalars( diff --git a/src/graphql_schema/entities/helpers/flight.py b/src/graphql_schema/entities/helpers/flight.py index bf636e7..01bf11d 100644 --- a/src/graphql_schema/entities/helpers/flight.py +++ b/src/graphql_schema/entities/helpers/flight.py @@ -1,10 +1,14 @@ +import asyncio from datetime import datetime -from typing import List, Type +from typing import List, Type, Literal, Optional, Tuple from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession +from strawberry.file_uploads import Upload + from database import models 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() @@ -74,8 +78,52 @@ async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: Combobo return obj.id -async def handle_copilot_edit(db: AsyncSession, copilot: ComboboxInput, user_id: int) -> int: - return await handle_combobox_save(db, models.Copilot, copilot, user_id) +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 handle_upload_gpx(flight: models.Flight, gpx_track: Upload): + path = "/app/uploads/tracks" + + if flight.gpx_track_filename and file_exists(path + "/" + flight.gpx_track_filename): + delete_file(path + "/" + flight.gpx_track_filename) + + 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( @@ -83,7 +131,7 @@ async def handle_combobox_save( input: ComboboxInput, user_id: int, name_column: str = "name" -): +) -> int: if input.id: return input.id else: diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index 891897b..7b99fde 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -3,6 +3,7 @@ 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.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input from upload_utils import get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists @@ -37,7 +38,7 @@ def get_photo_basepath(flight_id: int) -> str: @strawberry.type class PhotoQueries: - @strawberry.field + @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() @@ -52,6 +53,7 @@ class UploadPhotoMutation: photo: Upload @strawberry.mutation + @authenticated_user_only() async def upload_photo(self, info, input: UploadPhotoInput) -> Photo: path = get_photo_basepath(input.flight_id) filename = await handle_file_upload(input.photo, path) @@ -81,7 +83,8 @@ class EditPhotoMutation: class EditPhotoInput: pass - @strawberry.mutation + @strawberry.mutation() + @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() @@ -102,7 +105,8 @@ class EditPhotoMutation: @strawberry.type class DeletePhotoMutation: - @strawberry.mutation + @strawberry.mutation() + @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() @@ -110,7 +114,7 @@ class DeletePhotoMutation: base_path = get_photo_basepath(photo.flight_id) try: delete_file(f"{base_path}/{photo.filename}") - # TODO: odstranit nahledy + delete_file(f"{base_path}/thumbs/{photo.filename}") except Exception as e: print("ERROR", e) diff --git a/src/graphql_schema/entities/poi.py b/src/graphql_schema/entities/poi.py index ea4552d..4ac2f65 100644 --- a/src/graphql_schema/entities/poi.py +++ b/src/graphql_schema/entities/poi.py @@ -2,6 +2,7 @@ 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.dataloaders.photos import poi_photos_dataloader from graphql_schema.entities.photo import Photo from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input @@ -36,6 +37,7 @@ def get_base_query(user_id: int, only_my: bool = False): class PointOfInterestQueries: @strawberry.field + @authenticated_user_only() async def points_of_interest(root, info) -> List[PointOfInterest]: query = ( get_base_query(info.context.user_id) @@ -45,6 +47,7 @@ class PointOfInterestQueries: 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) @@ -60,6 +63,7 @@ class CreatePointOfInterestMutation: pass @strawberry.mutation + @authenticated_user_only() async def create_point_of_interest(root, info, input: CreatePointOfInterestInput) -> PointOfInterest: input_data = input.to_dict() return await models.PointOfInterest.create( @@ -78,6 +82,7 @@ class EditPointOfInterestMutation: pass @strawberry.mutation + @authenticated_user_only() async def edit_point_of_interest(root, info, id: int, input: EditPointOfInterestInput) -> PointOfInterest: # TODO: kontrola organizace # TODO: kontrola opravneni na akci @@ -94,7 +99,8 @@ class EditPointOfInterestMutation: class DeletePointOfInterestMutation: @strawberry.mutation + @authenticated_user_only() async def delete_point_of_interest(self, info, id: int) -> PointOfInterest: - poi = (await get_base_query(info.context.user_id, only_my=True).filter(models.PointOfInterest.id == id)).one() + 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)) diff --git a/src/graphql_schema/entities/user.py b/src/graphql_schema/entities/user.py index b8912d4..8a7108e 100644 --- a/src/graphql_schema/entities/user.py +++ b/src/graphql_schema/entities/user.py @@ -4,30 +4,29 @@ import strawberry from graphql import GraphQLError from passlib.hash import bcrypt from sqlalchemy import select -from sqlalchemy.exc import NoResultFound from strawberry.file_uploads import Upload +from config import API_URL 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, strawberry_sqlalchemy_input -from upload_utils import handle_file_upload, delete_file +from upload_utils import handle_file_upload, delete_file, get_public_url @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 f"http://localhost:8000/uploads/profile/{root.id}/{root.avatar_image_filename}" + 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 "http://localhost:8000/static/default-title-image.jpg" + return f"{API_URL}/static/default-title-image.jpg" - return f"http://localhost:8000/uploads/profile/{root.id}/{root.title_image_filename}" + 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) @@ -35,19 +34,17 @@ class User: @strawberry.type class UserQueries: - @strawberry.field + @strawberry.field() @error_logging async def user(root, info, username: str) -> User: if len(username) == 0: raise GraphQLError("Username not set!") - return (await info.context.db.scalars( - select(models.User).filter_by(public_username=username) - )).one() + return (await info.context.db.scalars(select(models.User).filter_by(public_username=username))).one() - @strawberry.field - @authenticated_user_only - # @error_logging + @strawberry.field() + @authenticated_user_only() + @error_logging async def logged_user(root, info) -> User: user = (await info.context.db.scalars( select(models.User).filter_by(id=info.context.user_id) @@ -71,7 +68,7 @@ class EditUserMutation: title_image: Optional[Upload] = None @strawberry.mutation - @authenticated_user_only + @authenticated_user_only() async def edit_logged_user(root, info, input: EditUserInput) -> User: user = (await info.context.db.scalars( select(models.User).filter_by(id=info.context.user_id) diff --git a/src/graphql_schema/sqlalchemy_to_strawberry_type.py b/src/graphql_schema/sqlalchemy_to_strawberry_type.py index 9ddfffa..a286cc8 100644 --- a/src/graphql_schema/sqlalchemy_to_strawberry_type.py +++ b/src/graphql_schema/sqlalchemy_to_strawberry_type.py @@ -1,4 +1,5 @@ import typing +from datetime import datetime from typing import List, Optional import strawberry import sqlalchemy @@ -44,18 +45,24 @@ 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): + 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): - return { - name: getattr(self, name) - for name, _ in get_columns_from_model(model, ignored_fields) - if getattr(self, name) is not None - } + dict_data = {} + for key, _ in get_columns_from_model(model, ignored_fields): + value = getattr(self, key) + if value is None: + continue + + if isinstance(value, datetime): + value = value.astimezone() + + dict_data[key] = value + return dict_data def wrapper(cls): annotations = get_annotations_for_scalars( diff --git a/src/main.py b/src/main.py index 217a24c..7d59f59 100644 --- a/src/main.py +++ b/src/main.py @@ -7,7 +7,7 @@ from starlette.middleware.cors import CORSMiddleware from starlette.responses import RedirectResponse, Response from starlette.staticfiles import StaticFiles from strawberry.fastapi import GraphQLRouter -from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG +from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS from dependencies.db import db_session from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint from endpoints.registration import RegistrationInput, RegistrationEndpoint @@ -19,7 +19,7 @@ class App: access_security = JwtAccessBearerCookie( secret_key=APP_SECRET_KEY, auto_error=False, - access_expires_delta=timedelta(seconds=20), + access_expires_delta=timedelta(minutes=20), ) refresh_security = JwtRefreshBearerCookie( secret_key=APP_SECRET_KEY, @@ -45,7 +45,7 @@ class App: def setup_middleware(app: FastAPI): app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:9001"], # TODO: pridat pres ENV URL produkce + allow_origins=ALLOW_CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -83,7 +83,7 @@ class App: if APP_DEBUG: @self.api_router.get("/graphql/autologin") async def autologin(): - access_token = self.access_security.create_access_token(subject={"id": 18, "name": "Franta Vomacka"}) + access_token = self.access_security.create_access_token(subject={"id": 1, "name": "Franta Vomacka"}) response = RedirectResponse(url="/graphql") self.access_security.set_access_cookie(response, access_token, expires_delta=timedelta(days=14)) diff --git a/src/upload_utils.py b/src/upload_utils.py index c041fab..8b569a0 100644 --- a/src/upload_utils.py +++ b/src/upload_utils.py @@ -6,10 +6,11 @@ import exif from PIL import Image, UnidentifiedImageError from strawberry.file_uploads import Upload +from config import API_URL + def get_public_url(filename: Optional[str]): - # TODO: pouzit staticfiles z /uploads - port na API, nginx nebude potreba (pro dev) - return f"http://localhost:8999/{filename}" if filename else None + return f"{API_URL}/uploads/{filename}" if filename else None def check_directories(path: str):