From ca500472e394378b2e7a3697b6fdfbec1da53b3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Kv=C3=A1=C4=8Dek?= Date: Mon, 7 Aug 2023 23:21:59 +0200 Subject: [PATCH] Stahovani pocasi, editace kopilota, nahravani GPX k letu --- ...e_nejsou_nullable_pridani__708fc32f6a3b.py | 40 ++++ ...-085710_photo_gps_altitude_cf1fb904e9ab.py | 28 +++ ...01-043717_add_weather_info_36ea098fde31.py | 38 ++++ ...d_datetime_to_weather_info_2a320e1e093d.py | 28 +++ ...7-185500_add_gpx_to_flight_1e04cd7838dd.py | 28 +++ ...irports_cannot_be_nullable_f85556b2ce2c.py | 38 ++++ ..._not_used_fields_in_flight_b5c66e5a2df7.py | 30 +++ requirements.txt | 5 + src/database/models.py | 41 ++-- src/endpoints/login.py | 1 + src/external/weather.py | 58 ++++++ src/graphql_schema/dataloaders/airport.py | 2 +- src/graphql_schema/dataloaders/flight.py | 4 +- src/graphql_schema/dataloaders/weather.py | 16 ++ src/graphql_schema/entities/aircraft.py | 11 +- src/graphql_schema/entities/copilot.py | 44 ++++- src/graphql_schema/entities/flight.py | 176 ++++++++++-------- src/graphql_schema/entities/helpers/flight.py | 96 ++++++++++ src/graphql_schema/entities/photo.py | 39 ++-- src/graphql_schema/mutation.py | 3 + src/graphql_schema/schema.py | 4 +- src/graphql_schema/types.py | 8 + src/main.py | 17 +- src/upload_utils.py | 47 ++++- 24 files changed, 680 insertions(+), 122 deletions(-) create mode 100644 alembic/versions/20230731-045126_gps_u_letiste_nejsou_nullable_pridani__708fc32f6a3b.py create mode 100644 alembic/versions/20230731-085710_photo_gps_altitude_cf1fb904e9ab.py create mode 100644 alembic/versions/20230801-043717_add_weather_info_36ea098fde31.py create mode 100644 alembic/versions/20230801-044722_add_datetime_to_weather_info_2a320e1e093d.py create mode 100644 alembic/versions/20230807-185500_add_gpx_to_flight_1e04cd7838dd.py create mode 100644 alembic/versions/20230807-191417_airports_cannot_be_nullable_f85556b2ce2c.py create mode 100644 alembic/versions/20230807-205300_remove_not_used_fields_in_flight_b5c66e5a2df7.py create mode 100644 src/external/weather.py create mode 100644 src/graphql_schema/dataloaders/weather.py create mode 100644 src/graphql_schema/entities/helpers/flight.py create mode 100644 src/graphql_schema/types.py diff --git a/alembic/versions/20230731-045126_gps_u_letiste_nejsou_nullable_pridani__708fc32f6a3b.py b/alembic/versions/20230731-045126_gps_u_letiste_nejsou_nullable_pridani__708fc32f6a3b.py new file mode 100644 index 0000000..eedecd6 --- /dev/null +++ b/alembic/versions/20230731-045126_gps_u_letiste_nejsou_nullable_pridani__708fc32f6a3b.py @@ -0,0 +1,40 @@ +"""gps u letiste nejsou nullable, pridani sloupce photo.exposed_at + +Revision ID: 708fc32f6a3b +Revises: 804e55bbf855 +Create Date: 2023-07-31 04:51:26.257834 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '708fc32f6a3b' +down_revision = '804e55bbf855' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('airport', 'gps_latitude', + existing_type=mysql.FLOAT(), + nullable=False) + op.alter_column('airport', 'gps_longitude', + existing_type=mysql.FLOAT(), + nullable=False) + op.add_column('photo', sa.Column('exposed_at', sa.DateTime(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('photo', 'exposed_at') + op.alter_column('airport', 'gps_longitude', + existing_type=mysql.FLOAT(), + nullable=True) + op.alter_column('airport', 'gps_latitude', + existing_type=mysql.FLOAT(), + nullable=True) + # ### end Alembic commands ### diff --git a/alembic/versions/20230731-085710_photo_gps_altitude_cf1fb904e9ab.py b/alembic/versions/20230731-085710_photo_gps_altitude_cf1fb904e9ab.py new file mode 100644 index 0000000..7b3a2ac --- /dev/null +++ b/alembic/versions/20230731-085710_photo_gps_altitude_cf1fb904e9ab.py @@ -0,0 +1,28 @@ +"""photo.gps_altitude + +Revision ID: cf1fb904e9ab +Revises: 708fc32f6a3b +Create Date: 2023-07-31 08:57:10.715298 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'cf1fb904e9ab' +down_revision = '708fc32f6a3b' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('photo', sa.Column('gps_altitude', sa.Float(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('photo', 'gps_altitude') + # ### end Alembic commands ### diff --git a/alembic/versions/20230801-043717_add_weather_info_36ea098fde31.py b/alembic/versions/20230801-043717_add_weather_info_36ea098fde31.py new file mode 100644 index 0000000..1087a45 --- /dev/null +++ b/alembic/versions/20230801-043717_add_weather_info_36ea098fde31.py @@ -0,0 +1,38 @@ +"""add weather info + +Revision ID: 36ea098fde31 +Revises: cf1fb904e9ab +Create Date: 2023-08-01 04:37:17.188376 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '36ea098fde31' +down_revision = 'cf1fb904e9ab' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('airport', sa.Column('created_by_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'airport', 'user', ['created_by_id'], ['id']) + op.add_column('flight', sa.Column('weather_info_takeoff_id', sa.Integer(), nullable=True)) + op.add_column('flight', sa.Column('weather_info_landing_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'flight', 'weather_info', ['weather_info_takeoff_id'], ['id']) + op.create_foreign_key(None, 'flight', 'weather_info', ['weather_info_landing_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('flight_ibfk_6', 'flight', type_='foreignkey') + op.drop_constraint('flight_ibfk_7', 'flight', type_='foreignkey') + op.drop_column('flight', 'weather_info_landing_id') + op.drop_column('flight', 'weather_info_takeoff_id') + op.drop_constraint('airport_ibfk_1', 'airport', type_='foreignkey') + op.drop_column('airport', 'created_by_id') + # ### end Alembic commands ### diff --git a/alembic/versions/20230801-044722_add_datetime_to_weather_info_2a320e1e093d.py b/alembic/versions/20230801-044722_add_datetime_to_weather_info_2a320e1e093d.py new file mode 100644 index 0000000..79d0d8a --- /dev/null +++ b/alembic/versions/20230801-044722_add_datetime_to_weather_info_2a320e1e093d.py @@ -0,0 +1,28 @@ +"""add datetime to weather_info + +Revision ID: 2a320e1e093d +Revises: 36ea098fde31 +Create Date: 2023-08-01 04:47:22.122423 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '2a320e1e093d' +down_revision = '36ea098fde31' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('weather_info', sa.Column('datetime', sa.DateTime(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('weather_info', 'datetime') + # ### end Alembic commands ### diff --git a/alembic/versions/20230807-185500_add_gpx_to_flight_1e04cd7838dd.py b/alembic/versions/20230807-185500_add_gpx_to_flight_1e04cd7838dd.py new file mode 100644 index 0000000..90f20e2 --- /dev/null +++ b/alembic/versions/20230807-185500_add_gpx_to_flight_1e04cd7838dd.py @@ -0,0 +1,28 @@ +"""add gpx to flight + +Revision ID: 1e04cd7838dd +Revises: 2a320e1e093d +Create Date: 2023-08-07 18:55:00.914684 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1e04cd7838dd' +down_revision = '2a320e1e093d' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('gpx_track_filename', sa.String(length=128), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('flight', 'gpx_track_filename') + # ### end Alembic commands ### diff --git a/alembic/versions/20230807-191417_airports_cannot_be_nullable_f85556b2ce2c.py b/alembic/versions/20230807-191417_airports_cannot_be_nullable_f85556b2ce2c.py new file mode 100644 index 0000000..aa17cd6 --- /dev/null +++ b/alembic/versions/20230807-191417_airports_cannot_be_nullable_f85556b2ce2c.py @@ -0,0 +1,38 @@ +"""airports cannot be nullable + +Revision ID: f85556b2ce2c +Revises: 1e04cd7838dd +Create Date: 2023-08-07 19:14:17.791435 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'f85556b2ce2c' +down_revision = '1e04cd7838dd' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('flight', 'takeoff_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.alter_column('flight', 'landing_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('flight', 'landing_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.alter_column('flight', 'takeoff_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + # ### end Alembic commands ### diff --git a/alembic/versions/20230807-205300_remove_not_used_fields_in_flight_b5c66e5a2df7.py b/alembic/versions/20230807-205300_remove_not_used_fields_in_flight_b5c66e5a2df7.py new file mode 100644 index 0000000..bbdb33b --- /dev/null +++ b/alembic/versions/20230807-205300_remove_not_used_fields_in_flight_b5c66e5a2df7.py @@ -0,0 +1,30 @@ +"""remove not used fields in flight + +Revision ID: b5c66e5a2df7 +Revises: f85556b2ce2c +Create Date: 2023-08-07 20:53:00.717813 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'b5c66e5a2df7' +down_revision = 'f85556b2ce2c' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('flight', 'with_instructor') + op.drop_column('flight', 'solo') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('flight', sa.Column('solo', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False)) + op.add_column('flight', sa.Column('with_instructor', mysql.TINYINT(display_width=1), autoincrement=False, nullable=False)) + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt index 584d78d..16e8144 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,8 @@ alembic==1.11.1 passlib==1.7.4 pydantic==1.10.11 # vysla uz 2.0, ale nejak mi to nefunguje +pillow +exif +aiocache +aiohttp + diff --git a/src/database/models.py b/src/database/models.py index 3ef7bb5..faa93e8 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -27,8 +27,7 @@ class BaseModel: async def create(cls, db_session: AsyncSession, data: dict): model = cls(**data) db_session.add(model) - # await db_session.flush() - # await db_session.refresh(model) + await db_session.flush() return model @@ -43,12 +42,10 @@ class BaseModel: 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) +# TODO: doplnit GPX k letu user_is_in_organization = Table( "user_is_in_organization", @@ -67,10 +64,13 @@ class Airport(BaseModel): 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) + is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=True) # automaticky import nebude mit ID deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') metars: Mapped['Metar'] = relationship(back_populates="airport") + created_by: Mapped['User'] = relationship() class PointOfInterestType(BaseModel): @@ -112,8 +112,10 @@ class Photo(BaseModel): filename: Mapped[str] = mapped_column(String(128), nullable=False) is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0") description: Mapped[str] = mapped_column(Text, nullable=False) + exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) gps_latitude: Mapped[float] = mapped_column(Float, nullable=True) gps_longitude: Mapped[float] = mapped_column(Float, nullable=True) + gps_altitude: Mapped[float] = mapped_column(Float, nullable=True) point_of_interest_id: Mapped[int] = mapped_column(Integer, ForeignKey("point_of_interest.id"), nullable=True) flight_id: Mapped[int] = mapped_column(Integer, ForeignKey("flight.id"), nullable=False) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) @@ -184,23 +186,38 @@ class FlightTrack(BaseModel): point_of_interest: Mapped['PointOfInterest'] = relationship() +class WeatherInfo(BaseModel): + __tablename__ = "weather_info" + + id: Mapped[int] = mapped_column(primary_key=True) + qnh: Mapped[int] = mapped_column(Integer, nullable=True) + temperature_surface: Mapped[Float] = mapped_column(Float, nullable=True) + dewpoint_surface: Mapped[Float] = mapped_column(Float, nullable=True) + rain: Mapped[Float] = mapped_column(Float, nullable=True) + cloudcover_low: Mapped[Float] = mapped_column(Float, nullable=True) + cloudcover_total: Mapped[Float] = mapped_column(Float, nullable=True) + wind_speed_surface: Mapped[Float] = mapped_column(Float, nullable=True) + wind_direction_surface: Mapped[Float] = mapped_column(Float, nullable=True) + datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) + + class Flight(BaseModel): __tablename__ = "flight" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False) + takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) + landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) takeoff_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) landing_datetime: Mapped[datetime] = mapped_column(DateTime, nullable=False) duration_total: Mapped[int] = mapped_column(Integer, nullable=True) duration_pic: Mapped[int] = mapped_column(Integer, nullable=True) - 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) + 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) - - solo: Mapped[bool] = mapped_column(Boolean, default=True) - with_instructor: Mapped[bool] = mapped_column(Boolean, default=False) + 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) landings: Mapped[int] = mapped_column(Integer, default=1) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) @@ -209,6 +226,8 @@ 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]) track: Mapped['FlightTrack'] = relationship() copilot: Mapped['Copilot'] = relationship(back_populates="flights") aircraft: Mapped['Aircraft'] = relationship(back_populates="flights") @@ -216,8 +235,6 @@ class Flight(BaseModel): 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" diff --git a/src/endpoints/login.py b/src/endpoints/login.py index 47656b8..c2f34b5 100644 --- a/src/endpoints/login.py +++ b/src/endpoints/login.py @@ -48,6 +48,7 @@ class LoginEndpoint(BaseEndpoint): "access_token_validity": self.access_security.access_expires_delta.total_seconds(), } + class RefreshEndpoint(BaseEndpoint): def __init__(self, access_token: JwtAccess, refresh_token: JwtRefresh): super().__init__(db=None) diff --git a/src/external/weather.py b/src/external/weather.py new file mode 100644 index 0000000..c4163af --- /dev/null +++ b/src/external/weather.py @@ -0,0 +1,58 @@ +import datetime +import urllib.parse +from typing import Tuple, Dict +import aiohttp +from aiocache import cached + + +class Weather: + FORECAST_URL = "https://api.open-meteo.com/v1/forecast?" + ARCHIVE_URL = "https://archive-api.open-meteo.com/v1/archive?" + TIMEZONE = "Europe/Prague" + METRICS = ( + "pressure_msl", "temperature_2m", "dewpoint_2m", "rain", "cloudcover_low", "cloudcover", "windspeed_10m", + "winddirection_10m" + ) + + def get_weather_info_url(self, start_date: datetime.date, end_date: datetime.date, gps: Tuple[float, float]) -> str: + today = datetime.datetime.now().date() + date_diff = today - end_date + + if date_diff.days >= 7: + # historical API offers data only older than 5 days + url = self.ARCHIVE_URL + else: + # forecast contains data even 14 days ago + url = self.FORECAST_URL + + params = { + "latitude": gps[0], + "longitude": gps[1], + "timezone": self.TIMEZONE, + "hourly": ','.join(self.METRICS), + "start_date": start_date, + "end_date": end_date, + } + + query_string = urllib.parse.urlencode(params) + return f"{url}{query_string}" + + @cached(ttl=6*3600) + async def download_weather_for_day(self, date: datetime.date, gps: Tuple[float, float]): + url = self.get_weather_info_url(start_date=date, end_date=date, gps=gps) + + async with aiohttp.ClientSession() as session: + async with session.get(url) as resp: + resp.raise_for_status() + return await resp.json() + + async def get_weather_for_hour(self, date_time: datetime.datetime, gps: Tuple[float, float]) -> Dict[str, float|str]: + data = await self.download_weather_for_day(date_time.date(), gps) + + # TODO: kontrola timezone! + # TODO: interpolace - udelat vazenyprumer z dvou po sobe jdoucich hodin + idx = date_time.hour + result_data = {metric: data['hourly'][metric][idx] for metric in self.METRICS} + result_data['datetime'] = datetime.datetime.strptime(data['hourly']['time'][idx], "%Y-%m-%dT%H:%M") + + return result_data \ No newline at end of file diff --git a/src/graphql_schema/dataloaders/airport.py b/src/graphql_schema/dataloaders/airport.py index a98d0da..da212e4 100644 --- a/src/graphql_schema/dataloaders/airport.py +++ b/src/graphql_schema/dataloaders/airport.py @@ -10,7 +10,7 @@ async def load(ids: List[int]): 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] + return [models_by_id[id_] for id_ in ids] airport_dataloader = DataLoader(load_fn=load, cache=False) diff --git a/src/graphql_schema/dataloaders/flight.py b/src/graphql_schema/dataloaders/flight.py index b7e58a1..ba799c4 100644 --- a/src/graphql_schema/dataloaders/flight.py +++ b/src/graphql_schema/dataloaders/flight.py @@ -24,4 +24,6 @@ class FlightsLoader: return [result_data[id_] for id_ in ids] -flights_by_copilot_dataloader = DataLoader(load_fn=FlightsLoader("copilot_id").load, cache=False) \ No newline at end of file + +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) diff --git a/src/graphql_schema/dataloaders/weather.py b/src/graphql_schema/dataloaders/weather.py new file mode 100644 index 0000000..4e3511b --- /dev/null +++ b/src/graphql_schema/dataloaders/weather.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 WeatherInfo + + +async def load(ids: List[int]): + async with async_session() as session: + models = (await session.scalars(select(WeatherInfo).filter(WeatherInfo.id.in_(ids)))).all() + + models_by_id = {model.id: model for model in models} + return [models_by_id.get(id_) for id_ in ids] + + +airport_weather_info_loader = DataLoader(load_fn=load, cache=False) diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 369be15..415e0e9 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -1,20 +1,29 @@ -from typing import List, Optional +from typing import List, Optional, Annotated, TYPE_CHECKING 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 from upload_utils import handle_file_upload, delete_file, get_public_url +from ..dataloaders.flight import flights_by_aircraft_dataloader + +if TYPE_CHECKING: + from .flight import Flight AIRCRAFT_UPLOAD_DEST_PATH = "/app/uploads/aircrafts/" @strawberry_sqlalchemy_type(models.Aircraft) class Aircraft: + async def load_flights(root): + return await flights_by_aircraft_dataloader.load(root.id) + photo_url: Optional[str] = strawberry.field( resolver=lambda root: get_public_url(f"aircrafts/{root.photo_filename}") if root.photo_filename else None ) + flights: List[Annotated["Flight", strawberry.lazy('.flight')]] = strawberry.field(resolver=load_flights) + def get_base_query(user_id: int): return ( diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index 2776ef0..6d9e2c9 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -3,13 +3,14 @@ import strawberry from sqlalchemy import select from database import models from graphql_schema.dataloaders.flight import flights_by_copilot_dataloader -from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type +from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input if TYPE_CHECKING: from .flight import Flight + @strawberry_sqlalchemy_type(models.Copilot) -class CopilotType: +class Copilot: async def load_flights(root): return await flights_by_copilot_dataloader.load(root.id) @@ -28,14 +29,49 @@ def get_base_query(user_id: int): @strawberry.type class CopilotQueries: @strawberry.field - async def copilots(root, info) -> List[CopilotType]: + async def copilots(root, info) -> List[Copilot]: return (await info.context.db.scalars( get_base_query(info.context.user_id) )).all() @strawberry.field - async def copilot(root, info, id: int) -> CopilotType: + async def copilot(root, info, id: int) -> Copilot: return (await info.context.db.scalars( get_base_query(info.context.user_id) .filter(models.Copilot.id == id) )).one() + + +@strawberry.type +class CreateCopilotMutation: + + @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) + class CreateCopilotInput: + pass + + @strawberry.mutation + async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot: + input_data = input.to_dict() + return await models.Copilot.create( + info.context.db, + data=dict( + **input_data, + created_by_id=info.context.user_id, + ) + ) + + +@strawberry.type +class EditCopilotMutation: + + @strawberry_sqlalchemy_input(model=models.Copilot, exclude_fields=["id"]) + class EditCopilotInput: + pass + + @strawberry.mutation + async def edit_copilot(root, info, id: int, input: EditCopilotInput) -> Copilot: + copilot = (await info.context.db.scalars( + get_base_query(info.context.user_id).filter(models.Copilot.id == id) + )).one() + + return await models.Copilot.update(info.context.db, obj=copilot, data=input.to_dict()) \ No newline at end of file diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index adfa706..ec3f265 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -1,8 +1,9 @@ from datetime import timedelta from typing import List, Optional, Annotated, TYPE_CHECKING import strawberry -from sqlalchemy import select, delete -from sqlalchemy.ext.asyncio import AsyncSession +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 @@ -11,26 +12,16 @@ from graphql_schema.dataloaders.photos import photos_dataloader, cover_photo_loa from graphql_schema.dataloaders.poi import flight_track_dataloader, poi_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.entities.photo import Photo from graphql_schema.entities.poi import PointOfInterest from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type, strawberry_sqlalchemy_input - +from upload_utils import get_public_url, handle_file_upload, check_directories, 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 ..types import ComboboxInput if TYPE_CHECKING: - from .copilot import CopilotType - - -@strawberry.input() -class PointOfInterestInput: - id: Optional[int] = None - name: str - - -@strawberry.input() -class CopilotInput: - id: Optional[int] = None - name: str + from .copilot import Copilot @strawberry_sqlalchemy_type(models.FlightTrack) @@ -41,6 +32,11 @@ class FlightTrack: point_of_interest: PointOfInterest = strawberry.field(resolver=load_poi) +@strawberry_sqlalchemy_type(models.WeatherInfo) +class WeatherInfo: + pass + + @strawberry_sqlalchemy_type(models.Flight) class Flight: async def load_takeoff_airport(root): @@ -64,6 +60,12 @@ class Flight: async def load_cover_photo(root): return await cover_photo_loader.load(root.id) + async def load_takeoff_weather_info(root): + return await airport_weather_info_loader.load(root.weather_info_takeoff_id) + + async def load_landing_weather_info(root): + return await airport_weather_info_loader.load(root.weather_info_landing_id) + def duration_min_calculated(root): if root.duration_total: return root.duration_total @@ -74,15 +76,23 @@ class Flight: return 0 + def load_gpx_track_url(root): + if not root.gpx_track_filename: + return None + + return f"http://localhost:8000/uploads/tracks/{root.gpx_track_filename}" + duration_min_calculated: int = strawberry.field(resolver=duration_min_calculated) - copilot: Optional[Annotated["CopilotType", strawberry.lazy(".copilot")]] = strawberry.field(resolver=load_copilot) + copilot: Optional[Annotated["Copilot", strawberry.lazy(".copilot")]] = 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) cover_photo: Optional[Photo] = strawberry.field(resolver=load_cover_photo) track: List[FlightTrack] = strawberry.field(resolver=load_track) - + takeoff_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_takeoff_weather_info) + landing_weather_info: Optional[WeatherInfo] = strawberry.field(resolver=load_landing_weather_info) photos: List[Photo] = strawberry.field(resolver=load_photos) + gpx_track_url: Optional[str] = strawberry.field(resolver=load_gpx_track_url) def get_base_query(user_id: int): @@ -96,12 +106,9 @@ def get_base_query(user_id: int): @strawberry.type class FlightQueries: - @strawberry.input - class FlightFilters: - takeoff: Optional[int] @strawberry.field - async def flights(root, info, filters: Optional[FlightFilters] = None) -> List[Flight]: + async def flights(root, info) -> List[Flight]: query = get_base_query(info.context.user_id).order_by(models.Flight.id.desc()) return (await info.context.db.scalars(query)).all() @@ -118,88 +125,95 @@ class FlightQueries: @strawberry.type class CreateFlightMutation: - @strawberry_sqlalchemy_input(models.Flight, exclude_fields=["id"]) + @strawberry_sqlalchemy_input(models.Flight, exclude_fields=[ + "id", "aircraft_id", "landing_airport_id", "takeoff_airport_id", "weather_info_takeoff_id", + "weather_info_landing_id", "with_instructor" + ]) class CreateFlightInput: - pass + aircraft: ComboboxInput + landing_airport: ComboboxInput + takeoff_airport: ComboboxInput @strawberry.mutation async def create_flight(self, info, input: CreateFlightInput) -> Flight: + aircraft_id = await handle_aircraft_save(info.context.db, info.context.user_id, input.aircraft) + takeoff_airport = (await info.context.db.scalars(select(models.Airport).filter(models.Airport.id == input.takeoff_airport.id))).one() + if input.takeoff_airport.id == input.landing_airport.id: + landing_airport = takeoff_airport + else: + landing_airport = (await info.context.db.scalars(select(models.Airport).filter(models.Airport.id == input.landing_airport.id))).one() + + weather_takeoff = await handle_weather_info(info.context.db, input.takeoff_datetime, takeoff_airport) + weather_landing = await handle_weather_info(info.context.db, input.landing_datetime, landing_airport) + return await models.Flight.create(info.context.db, data={ **input.to_dict(), + "weather_info_takeoff_id": weather_takeoff.id, + "weather_info_landing_id": weather_landing.id, + "takeoff_airport_id": takeoff_airport.id, + "landing_airport_id": landing_airport.id, + "aircraft_id": aircraft_id, "created_by_id": info.context.user_id }) -async def handle_track_edit(db: AsyncSession, flight: models.Flight, track: List[PointOfInterestInput], user_id: int): - await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight.id)) - - existing_poi_ids = [i.id for i in track if i.id] - poi_query = ( - select(models.PointOfInterest) - .filter(models.PointOfInterest.created_by_id == user_id) - .filter(models.PointOfInterest.id.in_(existing_poi_ids)) - ) - pois = (await db.scalars(poi_query)).all() - poi_map = {poi.id: poi for poi in pois} - - order = 0 - for item in track: - poi_object = None - if item.id: - poi_object = poi_map.get(item.id) - - if not poi_object: - poi_object = await models.PointOfInterest.create(db, data=dict(created_by_id=user_id, name=item.name)) - await db.flush() - - await models.FlightTrack.create( - db, - data={ - "flight_id": flight.id, - "point_of_interest_id": poi_object.id, - "order": order - } - ) - order += 1 - - -async def handle_copilot_edit(db: AsyncSession, copilot: CopilotInput, user_id: int) -> int: - if copilot.id: - return copilot.id - else: - copilot = await models.Copilot.create( - db, - data={ - "name": copilot.name, - "created_by_id": user_id, - } - ) - await db.flush() - return copilot.id - - @strawberry.type class EditFlightMutation: - @strawberry_sqlalchemy_input(models.Flight, exclude_fields=["id", "copilot_id", "deleted"], all_optional=True) + @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" + ], all_optional=True) class EditFlightInput: - track: Optional[List[PointOfInterestInput]] = None - copilot: Optional[CopilotInput] = None + gpx_track: Optional[Upload] = None + track: Optional[List[ComboboxInput]] = None + copilot: Optional[ComboboxInput] = None + aircraft: Optional[ComboboxInput] = None + landing_airport: Optional[ComboboxInput] = None + takeoff_airport: Optional[ComboboxInput] = None @strawberry.mutation async def edit_flight(self, info, id: int, input: EditFlightInput) -> Flight: # TODO: umoznit editovat jen vlastni lety! - flight = await models.Flight.update(info.context.db, id=id, data=input.to_dict()) + + flight = (await info.context.db.scalars( + get_base_query(info.context.user_id).filter(models.Flight.id == id) + )).one() + + data = input.to_dict() + + 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) + + if input.takeoff_airport is not None: + # TODO: stahnout nove pocasi na novem miste! Stejne tak pri zmene data/casu odletu + data['takeoff_airport_id'] = input.takeoff_airport.id + + if input.landing_airport is not None: + # TODO: stahnout nove pocasi na novem miste! Stejne tak pri zmene data/casu priletu + data['landing_airport_id'] = input.landing_airport.id + + if input.aircraft is not None: + data['aircraft_id'] = await handle_aircraft_save(info.context.db, info.context.user_id, input.aircraft) + + flight = await models.Flight.update(info.context.db, id=id, data=data) if input.track is not None: await handle_track_edit(db=info.context.db, flight=flight, track=input.track, user_id=info.context.user_id) - if flight.solo: - flight.copilot_id = None - elif input.copilot is not None: + if input.copilot: flight.copilot_id = await handle_copilot_edit(info.context.db, input.copilot, info.context.user_id) + else: + flight.copilot_id = None return flight + @strawberry.type class DeleteFlightMutation: @@ -214,4 +228,4 @@ class DeleteFlightMutation: ) flight.deleted = True - return flight \ No newline at end of file + return flight diff --git a/src/graphql_schema/entities/helpers/flight.py b/src/graphql_schema/entities/helpers/flight.py new file mode 100644 index 0000000..0042625 --- /dev/null +++ b/src/graphql_schema/entities/helpers/flight.py @@ -0,0 +1,96 @@ +from datetime import datetime +from typing import List, Type +from sqlalchemy import select, delete +from sqlalchemy.ext.asyncio import AsyncSession +from database import models +from external.weather import Weather +from graphql_schema.types import ComboboxInput + +weather_api = Weather() + + +async def handle_weather_info(db: AsyncSession, date_time: datetime, airport: models.Airport) -> models.WeatherInfo: + weather = await weather_api.get_weather_for_hour(date_time, (airport.gps_latitude, airport.gps_longitude)) + + return await models.WeatherInfo.create(db_session=db, data={ + "datetime": weather['datetime'], + "qnh": weather['pressure_msl'], + "temperature_surface": weather['temperature_2m'], + "dewpoint_surface": weather['dewpoint_2m'], + "rain": weather['rain'], + "cloudcover_total": weather['cloudcover'], + "cloudcover_low": weather['cloudcover_low'], + "wind_speed_surface": weather['windspeed_10m'], + "wind_direction_surface": weather['winddirection_10m'], + }) + + +async def handle_track_edit(db: AsyncSession, flight: models.Flight, track: List[ComboboxInput], user_id: int): + await db.execute(delete(models.FlightTrack).filter(models.FlightTrack.flight_id == flight.id)) + + existing_poi_ids = [i.id for i in track if i.id] + poi_query = ( + select(models.PointOfInterest) + .filter(models.PointOfInterest.created_by_id == user_id) + .filter(models.PointOfInterest.id.in_(existing_poi_ids)) + ) + pois = (await db.scalars(poi_query)).all() + poi_map = {poi.id: poi for poi in pois} + + order = 0 + for item in track: + poi_object = None + if item.id: + poi_object = poi_map.get(item.id) + + if not poi_object: + poi_object = await models.PointOfInterest.create(db, data=dict(created_by_id=user_id, name=item.name)) + await db.flush() + + await models.FlightTrack.create( + db, + data={ + "flight_id": flight.id, + "point_of_interest_id": poi_object.id, + "order": order + } + ) + order += 1 + + +async def handle_aircraft_save(db: AsyncSession, user_id: int, aircraft: ComboboxInput): + if aircraft.id: + return aircraft.id + else: + obj = await models.Aircraft.create(db, { + "call_sign": aircraft.name, + "description": "", + "model": "", + "manufacturer": "", + "created_by_id": user_id + + }) + await db.flush() + 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 handle_combobox_save( + db: AsyncSession, model: Type[models.BaseModel], + input: ComboboxInput, + user_id: int, + name_column: str = "name" +): + if input.id: + return input.id + else: + data = {name_column: input.name} + if hasattr(model, "created_by_id"): + data["created_by_id"] = user_id + + obj = await model.create(db, data) + await db.flush() + return obj.id diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index f914232..891897b 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -4,17 +4,23 @@ from sqlalchemy import select, update from strawberry.file_uploads import Upload from database import models 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 +from upload_utils import get_public_url, handle_file_upload, delete_file, parse_exif_info, generate_thumbnail, file_exists @strawberry_sqlalchemy_type(models.Photo) class Photo: - url: str = strawberry.field( - resolver=lambda root: get_public_url(f"photos/{root.flight_id}/{root.filename}") - ) - thumbnail_url: str = strawberry.field( - resolver=lambda root: get_public_url(f"photos/{root.flight_id}/{root.filename}") # TODO: doplnit thumb! - ) + def resolve_url(root): + return get_public_url(f"photos/{root.flight_id}/{root.filename}") + + def resolve_thumb_url(root): + thumbnail = get_photo_basepath(root.flight_id)+"/thumbs/"+root.filename + if not file_exists(thumbnail): + return get_public_url(f"photos/{root.flight_id}/{root.filename}") + + return get_public_url(f"photos/{root.flight_id}/thumbs/{root.filename}") + + url: str = strawberry.field(resolver=resolve_url) + thumbnail_url: str = strawberry.field(resolver=resolve_thumb_url) def get_base_query(user_id: int): @@ -39,28 +45,33 @@ class PhotoQueries: @strawberry.type class UploadPhotoMutation: - @strawberry_sqlalchemy_input(models.Photo, exclude_fields=["id", "filename", "is_flight_cover"]) + @strawberry_sqlalchemy_input(models.Photo, exclude_fields=[ + "id", "filename", "is_flight_cover", "exposed_at", "gps_latitude", "gps_longitude", "gps_altitude" + ]) class UploadPhotoInput: photo: Upload @strawberry.mutation async def upload_photo(self, info, input: UploadPhotoInput) -> Photo: - filename = await handle_file_upload(input.photo, get_photo_basepath(input.flight_id)) + path = get_photo_basepath(input.flight_id) + filename = await handle_file_upload(input.photo, path) + info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename, size=(300, 200)) - # todo: udelat nahled do thumbs slozky + exif_info = await parse_exif_info(path, filename) - is_flight_cover = False # TODO: pokud k letu neexistuje zadna fotka, vybrat nahodne jednu a tu nastavit jako cover created_photo = await models.Photo.create(data={ "flight_id": input.flight_id, "name": input.name, "filename": filename, "description": input.description, - "is_flight_cover": is_flight_cover, + "exposed_at": exif_info.get("datetime"), + "gps_latitude": exif_info.get("gps_latitude"), + "gps_longitude": exif_info.get("gps_longitude"), + "gps_altitude": exif_info.get("gps_altitude"), + "is_flight_cover": False, "created_by_id": info.context.user_id, }, db_session=info.context.db) - await info.context.db.flush() - return created_photo diff --git a/src/graphql_schema/mutation.py b/src/graphql_schema/mutation.py index 884e934..76272c4 100644 --- a/src/graphql_schema/mutation.py +++ b/src/graphql_schema/mutation.py @@ -1,5 +1,6 @@ from strawberry.tools import merge_types from graphql_schema.entities.aircraft import CreateAircraftMutation, EditAircraftMutation, DeleteAircraftMutation +from graphql_schema.entities.copilot import CreateCopilotMutation, EditCopilotMutation from graphql_schema.entities.flight import CreateFlightMutation, EditFlightMutation, DeleteFlightMutation from graphql_schema.entities.photo import UploadPhotoMutation, DeletePhotoMutation, EditPhotoMutation from graphql_schema.entities.poi import CreatePointOfInterestMutation, EditPointOfInterestMutation @@ -16,4 +17,6 @@ Mutation = merge_types("Mutation", ( DeletePhotoMutation, CreatePointOfInterestMutation, EditPointOfInterestMutation, + CreateCopilotMutation, + EditCopilotMutation, )) diff --git a/src/graphql_schema/schema.py b/src/graphql_schema/schema.py index 0c5422e..8aec9a1 100644 --- a/src/graphql_schema/schema.py +++ b/src/graphql_schema/schema.py @@ -1,8 +1,9 @@ import dataclasses import strawberry from fastapi_jwt import JwtAuthorizationCredentials -from fastapi_jwt.jwt import JwtAccessBearer, JwtAccessBearerCookie +from fastapi_jwt.jwt import JwtAccessBearerCookie from sqlalchemy.ext.asyncio import AsyncSession +from starlette.background import BackgroundTasks from strawberry.extensions import SchemaExtension from strawberry.fastapi import BaseContext from .mutation import Mutation @@ -33,6 +34,7 @@ class GraphQLContext(BaseContext): user_id: int jwt_auth_credentials: JwtAuthorizationCredentials jwt: JwtAccessBearerCookie + background_tasks: BackgroundTasks schema = strawberry.Schema( diff --git a/src/graphql_schema/types.py b/src/graphql_schema/types.py new file mode 100644 index 0000000..3616ca1 --- /dev/null +++ b/src/graphql_schema/types.py @@ -0,0 +1,8 @@ +from typing import Optional +import strawberry + + +@strawberry.input() +class ComboboxInput: + id: Optional[int] = None + name: str diff --git a/src/main.py b/src/main.py index 24d9947..488f78e 100644 --- a/src/main.py +++ b/src/main.py @@ -1,9 +1,11 @@ from datetime import timedelta from fastapi import FastAPI, APIRouter, Depends, Security, HTTPException -from fastapi_jwt import JwtAuthorizationCredentials, JwtRefreshBearer, JwtAccessBearerCookie, JwtRefreshBearerCookie +from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie from sqlalchemy.ext.asyncio import AsyncSession +from starlette.background import BackgroundTasks from starlette.middleware.cors import CORSMiddleware from starlette.responses import RedirectResponse, Response +from starlette.staticfiles import StaticFiles from starlette.status import HTTP_401_UNAUTHORIZED from strawberry.fastapi import GraphQLRouter from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG @@ -19,7 +21,7 @@ class App: access_security = JwtAccessBearerCookie( secret_key=APP_SECRET_KEY, auto_error=False, - access_expires_delta=timedelta(seconds=30) + access_expires_delta=timedelta(minutes=20) ) refresh_security = JwtRefreshBearerCookie( secret_key=APP_SECRET_KEY, @@ -41,6 +43,7 @@ class App: @staticmethod def setup_middleware(app: FastAPI): + print("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:9001"], @@ -62,7 +65,7 @@ class App: def setup_graphql_context( credentials: JwtAuthorizationCredentials = Security(self.access_security), - db: AsyncSession = Depends(db_session), + db: AsyncSession = Depends(db_session) ): if not credentials: raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) @@ -71,7 +74,8 @@ class App: jwt_auth_credentials=credentials, user_id=credentials['id'], db=db, - jwt=self.access_security + jwt=self.access_security, + background_tasks=Depends(BackgroundTasks) ) graphql_app = GraphQLRouter( @@ -96,11 +100,12 @@ class App: resp: Response, credentials: JwtAuthorizationCredentials = Security(self.refresh_security) ): - return await RefreshEndpoint(self.access_security, self.refresh_security).on_post(resp, credentials) - + return await RefreshEndpoint(self.access_security, self.refresh_security).on_post(resp, credentials) self.setup_graphql_endpoint(app) + app.mount("/uploads", StaticFiles(directory="/app/uploads"), name="uploads") + # public endpoints @self.api_router.post("/login") diff --git a/src/upload_utils.py b/src/upload_utils.py index 795658c..c041fab 100644 --- a/src/upload_utils.py +++ b/src/upload_utils.py @@ -1,10 +1,14 @@ import os import uuid -from typing import Optional +from datetime import datetime +from typing import Optional, Tuple +import exif +from PIL import Image, UnidentifiedImageError from strawberry.file_uploads import Upload 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 @@ -13,6 +17,10 @@ def check_directories(path: str): os.makedirs(path) +def file_exists(path: str): + return os.path.isfile(path) + + async def handle_file_upload(file: Upload, path: str): check_directories(path) @@ -26,5 +34,42 @@ async def handle_file_upload(file: Upload, path: str): return filename +def gps_to_decimal(input: Tuple[float, float, float]) -> float: + d, m, s = input + return d + (m / 60.0) + (s / 3600.0) + + +async def parse_exif_info(path: str, filename: str) -> dict: + with open(f"{path}/{filename}", "rb") as f: + img = exif.Image(f) + if not img.has_exif: + return {} + + exif_info = img.get_all() + + for datetime_field in ("datetime", "datetime_original", "datetime_digitized"): + if exif_info.get(datetime_field): + print(exif_info[datetime_field]) + exif_info[datetime_field] = datetime.strptime(exif_info[datetime_field], "%Y:%m:%d %H:%M:%S") + + if exif_info.get("gps_latitude"): + exif_info["gps_latitude"] = gps_to_decimal(exif_info["gps_latitude"]) + + if exif_info.get("gps_longitude"): + exif_info["gps_longitude"] = gps_to_decimal(exif_info["gps_longitude"]) + + return exif_info + + +async def generate_thumbnail(path: str, filename: str, size: Tuple[int, int]): + try: + image = Image.open(f"{path}/{filename}") + image.thumbnail(size) + check_directories(f"{path}/thumbs/") + image.save(f"{path}/thumbs/{filename}") + except UnidentifiedImageError: + pass + + def delete_file(path: str, silent: bool = False): os.remove(path)