diff --git a/alembic/versions/20240103-061713_pridani_slugu_odstraneni_deprecated__6648fb80dd0e.py b/alembic/versions/20240103-061713_pridani_slugu_odstraneni_deprecated__6648fb80dd0e.py new file mode 100644 index 0000000..a885764 --- /dev/null +++ b/alembic/versions/20240103-061713_pridani_slugu_odstraneni_deprecated__6648fb80dd0e.py @@ -0,0 +1,60 @@ +"""pridani slugu, odstraneni deprecated sloupcu, pridani defaultu ke jmenu a popisumm + +Revision ID: 6648fb80dd0e +Revises: 022bf6fbcd47 +Create Date: 2024-01-03 06:17:13.457060 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = '6648fb80dd0e' +down_revision = '022bf6fbcd47' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('aircraft', 'photo_filename') + op.add_column('airport', sa.Column('airport_type', sa.Enum('airport', 'ull', 'heliport'), server_default='airport', nullable=False)) + op.add_column('airport', sa.Column('use_in_gpx_guess', sa.Boolean(), server_default='1', nullable=False)) + op.add_column('copilot', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False)) + op.add_column('copilot', sa.Column('is_public', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('event', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False)) + op.add_column('flight', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False)) + op.alter_column('flight', 'takeoff_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.alter_column('flight', 'landing_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=True) + op.add_column('photo', sa.Column('filename_extension', sa.String(length=4), nullable=False)) + op.add_column('photo', sa.Column('cache_key', sa.String(length=128), nullable=True)) + op.drop_column('photo', 'is_flight_cover') + op.add_column('point_of_interest', sa.Column('url_slug', sa.String(length=128), server_default='', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('point_of_interest', 'url_slug') + op.add_column('photo', sa.Column('is_flight_cover', mysql.TINYINT(display_width=1), server_default=sa.text('0'), autoincrement=False, nullable=False)) + op.drop_column('photo', 'cache_key') + op.drop_column('photo', 'filename_extension') + op.alter_column('flight', 'landing_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.alter_column('flight', 'takeoff_airport_id', + existing_type=mysql.INTEGER(display_width=11), + nullable=False) + op.drop_column('flight', 'url_slug') + op.drop_column('event', 'url_slug') + op.drop_column('copilot', 'is_public') + op.drop_column('copilot', 'url_slug') + op.drop_column('airport', 'use_in_gpx_guess') + op.drop_column('airport', 'airport_type') + op.add_column('aircraft', sa.Column('photo_filename', mysql.VARCHAR(length=128), nullable=True)) + # ### end Alembic commands ### diff --git a/src/background_jobs/photo.py b/src/background_jobs/photo.py index f895a03..627d6d5 100644 --- a/src/background_jobs/photo.py +++ b/src/background_jobs/photo.py @@ -1,3 +1,5 @@ +import os.path +from time import time from database import models from database.transaction import get_session from utils.image import PhotoEditor @@ -6,14 +8,34 @@ from utils.image import PhotoEditor async def resize_photo(path: str, filename: str, photo_id: int): editor = PhotoEditor(path, filename) editor.resize(new_width=2500) - editor.write_to_file(quality=85) + + name, _ = os.path.splitext(filename) + editor.write_to_file(quality=85, format_="webp", dest_filename=f"{name}.webp") + editor.write_to_file(quality=85) # toto je potreba pro prvni nacteni nahledu ihned po nahrani, pripadne pro + # vygenerovani nahledu (async, muze se delat pred/behem zmensovani fotky -> v tu dobu jeste neexistuje webp) width, height = editor.img_size + # TODO: doresit uklid JPGu -> jsou zbytecne + async with get_session() as db: - await models.Photo.update(db, {"width": width, "height": height}, id=photo_id) + await models.Photo.update( + db, + id=photo_id, + data={ + "width": width, + "height": height, + "filename_extension": "webp", + "cache_key": int(time()) + }) async def generate_thumbnail(path: str, filename: str): editor = PhotoEditor(path, filename) editor.resize(new_width=300) - editor.write_to_file(quality=85, dest_path=f"{path}/thumbs") + + name, _ = os.path.splitext(filename) + editor.write_to_file( + quality=85, + dest_path=f"{path}/thumbs", + dest_filename=f"{name}.webp", + format_="webp") diff --git a/src/background_jobs/weather.py b/src/background_jobs/weather.py index d251235..ba1e7a7 100644 --- a/src/background_jobs/weather.py +++ b/src/background_jobs/weather.py @@ -13,6 +13,7 @@ async def download_weather(date_time: datetime, flight_id: int, airport_id: int, try: weather = await weather_api.get_weather_for_hour(date_time.astimezone(), gps=gps) + log.warning(weather) except Exception as e: log.error(f"Error in downloading weather: {e}") return None diff --git a/src/database/models.py b/src/database/models.py index 5e55697..501d3a8 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime from typing import Set, List -from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float +from sqlalchemy import String, DateTime, ForeignKey, Text, Integer, func, Table, Column, Boolean, select, Float, Enum from sqlalchemy.orm import Mapped, relationship, as_declarative, mapped_column from sqlalchemy.ext.asyncio import AsyncSession @@ -74,6 +74,8 @@ class Airport(BaseModel): gps_latitude: Mapped[float] = mapped_column(Float, nullable=True) gps_longitude: Mapped[float] = mapped_column(Float, nullable=True) elevation: Mapped[int] = mapped_column(Integer, nullable=True) + airport_type: Mapped[str] = mapped_column(Enum("airport", "ull", "heliport"), nullable=False, server_default='airport') # noqa + use_in_gpx_guess: Mapped[bool] = mapped_column(Boolean, server_default='1') is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id'), nullable=True) # automaticky import nebude mit ID # noqa @@ -103,7 +105,8 @@ class PointOfInterest(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True) gps_latitude: Mapped[float] = mapped_column(Float, nullable=True) gps_longitude: Mapped[float] = mapped_column(Float, nullable=True) @@ -122,10 +125,11 @@ class Photo(BaseModel): __tablename__ = "photo" id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(128), nullable=False) + name: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") + description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") filename: Mapped[str] = mapped_column(String(128), nullable=False) - is_flight_cover: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="0") # TODO: odstranit - description: Mapped[str] = mapped_column(Text, nullable=False) + filename_extension: Mapped[str] = mapped_column(String(4), nullable=False) + cache_key: Mapped[str] = mapped_column(String(128), nullable=True) width: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False) exposed_at: Mapped[datetime] = mapped_column(DateTime, nullable=True) @@ -172,7 +176,6 @@ class Aircraft(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) call_sign: Mapped[str] = mapped_column(String(16), nullable=False) - photo_filename: Mapped[str] = mapped_column(String(128), nullable=True) # TODO: odstranit title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True) is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') manufacturer: Mapped[str] = mapped_column(Text, nullable=False, server_default="") @@ -253,7 +256,8 @@ class Event(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") date_from: Mapped[datetime] = mapped_column(DateTime, nullable=True) date_to: Mapped[datetime] = mapped_column(DateTime, nullable=True) organization_id: Mapped[int] = mapped_column(Integer, ForeignKey('organization.id'), nullable=True) @@ -270,13 +274,13 @@ 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) + name: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") + description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") + url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") event_id: Mapped[int] = mapped_column(Integer, ForeignKey("event.id"), nullable=True) title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True) - - takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) - landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=False) + takeoff_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True) + landing_airport_id: Mapped[int] = mapped_column(Integer, ForeignKey("airport.id"), nullable=True) 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) @@ -310,7 +314,9 @@ class Copilot(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(128), nullable=False) + url_slug: Mapped[str] = mapped_column(String(128), nullable=False, server_default="") title_photo_id: Mapped[int] = mapped_column(Integer, ForeignKey('photo.id'), nullable=True) + is_public: Mapped[bool] = mapped_column(Boolean, server_default='0') created_by_id: Mapped[int] = mapped_column(Integer, ForeignKey('user.id')) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='0') @@ -354,7 +360,7 @@ class User(BaseModel): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) name: Mapped[str] = mapped_column(String(128), nullable=False) - description: Mapped[str] = mapped_column(Text, nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, server_default="") public_username: Mapped[str] = mapped_column(String(128), nullable=True, unique=True) avatar_image_filename: Mapped[str] = mapped_column(String(128), nullable=True) title_image_filename: Mapped[str] = mapped_column(String(128), nullable=True) diff --git a/src/database/query_builder.py b/src/database/query_builder.py index c0e7cd1..8620883 100644 --- a/src/database/query_builder.py +++ b/src/database/query_builder.py @@ -25,6 +25,8 @@ class QueryBuilder: if only_public and hasattr(self.model, "is_public"): query = query.filter(self.model.is_public.is_(True)) + if hasattr(self.model, "url_slug"): + query = query.filter(self.model.url_slug != '') elif hasattr(self.model, "created_by_id") and created_by_id: query = query.filter(or_( self.model.created_by_id.is_(None), @@ -33,5 +35,7 @@ class QueryBuilder: if order_by: query = query.order_by(*order_by) + elif hasattr(self.model, "name"): + query = query.order_by(self.model.name) return query diff --git a/src/graphql_schema/entities/aircraft.py b/src/graphql_schema/entities/aircraft.py index 9f1ab62..1534bc6 100644 --- a/src/graphql_schema/entities/aircraft.py +++ b/src/graphql_schema/entities/aircraft.py @@ -29,12 +29,25 @@ class AircraftQueries: @strawberry.field() @error_logging @allow_public - async def aircraft(root, info, id: int, public: Optional[bool] = False) -> Aircraft: + async def aircraft( + root, info, + id: Optional[int] = None, + call_sign: Optional[str] = None, + public: Optional[bool] = False + ) -> Aircraft: + filter_params = {} + + if id: + filter_params['object_id'] = id + + if call_sign: + filter_params['call_sign'] = call_sign + return await AircraftQueryResolver().get_one( - id, user_id=info.context.user_id, organization_ids=info.context.organization_ids, - public=public + only_public=public, + **filter_params ) @@ -44,13 +57,13 @@ class AircraftMutation: @error_logging @authenticated_user_only() async def create_aircraft(root, info, input: CreateAircraftInput) -> Aircraft: - return await AircraftMutationResolver().create_new(input, info.context.user_id) + return await AircraftMutationResolver().create(info.context, input) @strawberry.mutation @error_logging @authenticated_user_only() async def edit_aircraft(root, info, id: int, input: EditAircraftInput) -> Aircraft: - return await AircraftMutationResolver().edit(id, user_id=info.context.user_id, data=input) + return await AircraftMutationResolver().update(id, user_id=info.context.user_id, data=input) @strawberry.mutation @authenticated_user_only() diff --git a/src/graphql_schema/entities/airport.py b/src/graphql_schema/entities/airport.py index d7575ee..bad2fa5 100644 --- a/src/graphql_schema/entities/airport.py +++ b/src/graphql_schema/entities/airport.py @@ -19,4 +19,7 @@ class AirportQueries: @error_logging @authenticated_user_only() async def airport(root, info, id: int) -> Airport: - return await BaseQueryResolver(Airport, models.Airport).get_one(id, user_id=info.context.user_id) + return await BaseQueryResolver(Airport, models.Airport).get_one( + object_id=id, + user_id=info.context.user_id + ) diff --git a/src/graphql_schema/entities/copilot.py b/src/graphql_schema/entities/copilot.py index e9d585d..651b66c 100644 --- a/src/graphql_schema/entities/copilot.py +++ b/src/graphql_schema/entities/copilot.py @@ -4,6 +4,7 @@ from strawberry.types import Info from database import models from decorators.error_logging import error_logging from decorators.endpoints import authenticated_user_only, allow_public +from graphql_schema.entities.helpers.detail import get_detail_filters from graphql_schema.entities.resolvers.base import BaseMutationResolver from graphql_schema.entities.resolvers.copilot import CopilotQueryResolver from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput @@ -21,11 +22,22 @@ class CopilotQueries: @strawberry.field() @error_logging @allow_public - async def copilot(root, info: Info, id: int, pilot_username: Optional[str] = None) -> Copilot: + async def copilot( + root, info: Info, + id: Optional[int] = None, + url_slug: Optional[str] = None, + pilot_username: Optional[str] = None, + public: Optional[bool] = False + ) -> Copilot: + + filter_params = get_detail_filters(id, url_slug) + if pilot_username: + filter_params['pilot_username'] = pilot_username + return await CopilotQueryResolver().get_one( - id, user_id=info.context.user_id, - pilot_username=pilot_username + only_public=public, + **filter_params ) @@ -35,10 +47,7 @@ class CopilotMutation: @error_logging @authenticated_user_only() async def create_copilot(root, info, input: CreateCopilotInput) -> Copilot: - return await BaseMutationResolver(Copilot, models.Copilot).create( - data=input.to_dict(), - user_id=info.context.user_id - ) + return await BaseMutationResolver(Copilot, models.Copilot).create(info.context, data=input) @strawberry.mutation @error_logging diff --git a/src/graphql_schema/entities/event.py b/src/graphql_schema/entities/event.py index a744108..6d9d6c5 100644 --- a/src/graphql_schema/entities/event.py +++ b/src/graphql_schema/entities/event.py @@ -3,6 +3,7 @@ import strawberry from database import models from decorators.endpoints import authenticated_user_only, allow_public from decorators.error_logging import error_logging +from graphql_schema.entities.helpers.detail import get_detail_filters from graphql_schema.entities.helpers.pagination import PaginationWindow, get_pagination_window from graphql_schema.entities.resolvers.base import BaseMutationResolver from graphql_schema.entities.resolvers.event import EventQueryResolver @@ -40,12 +41,21 @@ class EventQueries: @strawberry.field() @error_logging @allow_public - async def event(root, info, id: int, username: Optional[str] = None, public: Optional[bool] = False) -> Event: + async def event( + root, info, + id: Optional[int] = None, + url_slug: Optional[str] = None, + username: Optional[str] = None, + public: Optional[bool] = False + ) -> Event: + filter_params = get_detail_filters(id, url_slug) + if username: + filter_params['username'] = username + return await EventQueryResolver().get_one( - id, - username=username, public=public, - user_id=info.context.user_id + user_id=info.context.user_id , + ** filter_params ) @@ -55,7 +65,7 @@ class EventMutation: @error_logging @authenticated_user_only() async def create_event(root, info, input: CreateEventInput) -> Event: - return await BaseMutationResolver(Event, models.Event).create(input.to_dict(), info.context.user_id) + return await BaseMutationResolver(Event, models.Event).create(info.context, input) @strawberry.mutation @error_logging diff --git a/src/graphql_schema/entities/flight.py b/src/graphql_schema/entities/flight.py index e5fef59..6ade4d7 100644 --- a/src/graphql_schema/entities/flight.py +++ b/src/graphql_schema/entities/flight.py @@ -10,6 +10,7 @@ from graphql_schema.entities.resolvers.flight import handle_aircraft_save, Fligh from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput from graphql_schema.entities.types.types import Flight from .helpers.combobox import handle_combobox_save +from .helpers.detail import get_detail_filters from .helpers.pagination import PaginationWindow, get_pagination_window @@ -50,12 +51,21 @@ class FlightQueries: @strawberry.field() @error_logging @allow_public - async def flight(root, info, id: int, username: Optional[str] = None, public: Optional[bool] = False) -> Flight: + async def flight( + root, info, + id: Optional[int] = None, + url_slug: Optional[str] = None, + username: Optional[str] = None, + public: Optional[bool] = False + ) -> Flight: + filter_params = get_detail_filters(id, url_slug) + if username: + filter_params['username'] = username + return await FlightQueryResolver().get_one( - id, user_id=info.context.user_id, - username=username, - public=public + only_public=public, + **filter_params ) @@ -65,43 +75,7 @@ class FlightMutation: @error_logging @authenticated_user_only() async def create_flight(self, info, input: CreateFlightInput) -> Flight: - data = input.to_dict() - user_id = info.context.user_id - async with get_session() as db: - aircraft_id = await handle_aircraft_save(db, info.context.user_id, input.aircraft) - - # TODO: tohle je blbost, bude to vyrabet dve stejne instance! - takeoff_airport_id, landing_airport_id = await asyncio.gather( - handle_combobox_save( - db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code", - extra_data={"name": input.takeoff_airport.name} - ), - handle_combobox_save( - db, models.Airport, input.landing_airport, user_id, name_column="icao_code", - extra_data={"name": input.landing_airport.name} - ) - ) - - data.update({ - "takeoff_airport_id": takeoff_airport_id, - "landing_airport_id": landing_airport_id, - "aircraft_id": aircraft_id, - "has_terrain_elevation": False, - "name": "", - "description": "" - }) - flight = await FlightMutationResolver().create(data, info.context.user_id) - - info.context.background_tasks.add_task( - download_weather, - flight_id=flight.id, airport_id=takeoff_airport_id, date_time=flight.takeoff_datetime, type_="takeoff" - ) - info.context.background_tasks.add_task( - download_weather, - flight_id=flight.id, airport_id=landing_airport_id, date_time=flight.landing_datetime, type_="landing" - ) - - return flight + return await FlightMutationResolver().create(info.context, input) @strawberry.mutation @error_logging diff --git a/src/graphql_schema/entities/helpers/combobox.py b/src/graphql_schema/entities/helpers/combobox.py index ccdb94f..5ff7471 100644 --- a/src/graphql_schema/entities/helpers/combobox.py +++ b/src/graphql_schema/entities/helpers/combobox.py @@ -15,10 +15,11 @@ async def handle_combobox_save( if input.id: return input.id else: - if not extra_data: extra_data = {} + # TODO: vyhledat podle `name_column` existujici zaznam, pokud nebude, az pak vytvorit + data = {name_column: input.name, **extra_data} if hasattr(model, "created_by_id"): data["created_by_id"] = user_id diff --git a/src/graphql_schema/entities/helpers/detail.py b/src/graphql_schema/entities/helpers/detail.py new file mode 100644 index 0000000..ddd4fc9 --- /dev/null +++ b/src/graphql_schema/entities/helpers/detail.py @@ -0,0 +1,15 @@ +from typing import Optional +from graphql import GraphQLError + + +def get_detail_filters(id: Optional[int], url_slug: Optional[str]) -> dict: + filter_params = {} + if id: + filter_params['object_id'] = id + if url_slug is not None: + filter_params['url_slug'] = url_slug + + if not filter_params: + raise GraphQLError("You must specifiy either urlSlug or id!") + + return filter_params diff --git a/src/graphql_schema/entities/organization.py b/src/graphql_schema/entities/organization.py index f02c30c..bcc0f96 100644 --- a/src/graphql_schema/entities/organization.py +++ b/src/graphql_schema/entities/organization.py @@ -19,14 +19,18 @@ class OrganizationQueries: @authenticated_user_only() async def organizations(root, info) -> List[Organization]: return await BaseQueryResolver(Organization, models.Organization).get_list( - info.context.user_id, order_by=[models.Organization.name] + info.context.user_id, + order_by=[models.Organization.name] ) @strawberry.field() @error_logging @authenticated_user_only() async def organization(root, info, id: int) -> Organization: - return await BaseQueryResolver(Organization, models.Organization).get_one(id, info.context.user_id) + return await BaseQueryResolver(Organization, models.Organization).get_one( + object_id=id, + user_id=info.context.user_id + ) @strawberry.type @@ -36,24 +40,17 @@ class OrganizationMutation: @error_logging @authenticated_user_only() async def create_organization(root, info, input: CreateOrganizationInput) -> Organization: - return await BaseMutationResolver(Organization, models.Organization).create( - data=input.to_dict(), - user_id=info.context.user_id - ) + return await BaseMutationResolver(Organization, models.Organization).create(info.context, data=input) @strawberry.mutation @error_logging @authenticated_user_only() async def edit_organization(root, info, id: int, input: EditOrganizationInput) -> Organization: - async with get_session() as db: - organization = (await db.scalars( - BaseQueryResolver(Organization, models.Organization).get_query( - user_id=info.context.user_id, object_id=id - ) - )).one() - - updated_organization = await models.Organization.update(db, obj=organization, data=input.to_dict()) - return Organization(**updated_organization.as_dict()) + return await BaseMutationResolver(Organization, models.Organization).update( + id, + data=input, + user_id=info.context.user_id + ) @strawberry.type diff --git a/src/graphql_schema/entities/photo.py b/src/graphql_schema/entities/photo.py index 70c517d..060ee50 100644 --- a/src/graphql_schema/entities/photo.py +++ b/src/graphql_schema/entities/photo.py @@ -36,7 +36,11 @@ class PhotoQueries: @error_logging @allow_public async def photo(root, info, id: int, public: Optional[bool] = False,) -> Photo: - return await BaseQueryResolver(Photo, models.Photo).get_one(id, user_id=info.context.user_id, public=public) + return await BaseQueryResolver(Photo, models.Photo).get_one( + object_id=id, + user_id=info.context.user_id, + only_public=public + ) @strawberry.type diff --git a/src/graphql_schema/entities/poi.py b/src/graphql_schema/entities/poi.py index 7a2062a..8ce4375 100644 --- a/src/graphql_schema/entities/poi.py +++ b/src/graphql_schema/entities/poi.py @@ -1,16 +1,18 @@ -from typing import List +from typing import List, Optional import strawberry +from graphql import GraphQLError + from database import models from decorators.endpoints import authenticated_user_only, allow_public from database.transaction import get_session from decorators.error_logging import error_logging from graphql_schema.entities.helpers.combobox import handle_combobox_save +from graphql_schema.entities.helpers.detail import get_detail_filters from graphql_schema.entities.helpers.pagination import get_pagination_window, PaginationWindow from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver from graphql_schema.entities.types.types import PointOfInterest from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput - @strawberry.type class PointOfInterestQueries: @strawberry.field() @@ -33,10 +35,18 @@ class PointOfInterestQueries: @strawberry.field() @allow_public - async def point_of_interest(root, info, id: int, public: bool = False) -> PointOfInterest: + async def point_of_interest( + root, info, + url_slug: Optional[str] = None, + id: Optional[int] = None, + public: bool = False + ) -> PointOfInterest: + filter_params = get_detail_filters(id, url_slug) + return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_one( - id, info.context.user_id, - only_public=public + user_id=info.context.user_id, + only_public=public, + **filter_params ) @@ -54,9 +64,10 @@ class PointOfInterestMutation: db, models.PointOfInterestType, input.type, info.context.user_id ) - return await BaseMutationResolver(PointOfInterest, models.PointOfInterest).create( - input_data, info.context.user_id - ) + input_data['created_by_id'] = info.context.user_id + return await BaseMutationResolver(PointOfInterest, models.PointOfInterest)._do_create( + db, input_data + ) @strawberry.mutation @error_logging diff --git a/src/graphql_schema/entities/poi_type.py b/src/graphql_schema/entities/poi_type.py index 2805b63..2cb9076 100644 --- a/src/graphql_schema/entities/poi_type.py +++ b/src/graphql_schema/entities/poi_type.py @@ -21,7 +21,8 @@ class PointOfInterestTypeQueries: @authenticated_user_only() async def point_of_interest_type(root, info, id: int) -> PointOfInterestType: return await BaseQueryResolver(PointOfInterestType, models.PointOfInterestType).get_one( - id, info.context.user_id + object_id=id, + user_id=info.context.user_id ) # diff --git a/src/graphql_schema/entities/resolvers/aircraft.py b/src/graphql_schema/entities/resolvers/aircraft.py index a7fafb5..fb69672 100644 --- a/src/graphql_schema/entities/resolvers/aircraft.py +++ b/src/graphql_schema/entities/resolvers/aircraft.py @@ -21,10 +21,17 @@ class AircraftQueryResolver(BaseQueryResolver): *args, **kwargs, ): + call_sign = kwargs.get("call_sign") + filters = {} + if object_id: + filters['object_id'] = object_id + if call_sign: + filters['call_sign'] = call_sign + query = super().get_query( order_by=[models.Aircraft.id.desc()], - object_id=object_id, - user_id=user_id if not organization_ids else None + user_id=user_id if not organization_ids else None, + **filters ) if organization_ids: query = ( @@ -43,20 +50,22 @@ class AircraftMutationResolver(BaseMutationResolver): def __init__(self): super().__init__(graphql_type=Aircraft, model=models.Aircraft) - async def create_new(self, data: CreateAircraftInput, user_id: int) -> Aircraft: + async def create(self, context, data: CreateAircraftInput) -> Aircraft: input_data = data.to_dict() - if data.organization: - async with get_session() as db: + + async with get_session() as db: + input_data['created_by_id'] = context.user_id + if data.organization: input_data['organization_id'] = await handle_combobox_save( db, models.Organization, input=data.organization, - user_id=user_id, + user_id=context.user_id, ) - return await self.create(data=input_data, user_id=user_id) + return await self._do_create(db, data=input_data) - async def edit(self, id: int, user_id: int, data: EditAircraftInput) -> Aircraft: + async def update(self, id: int, user_id: int, data: EditAircraftInput) -> Aircraft: update_data = data.to_dict() async with get_session() as db: if data.organization: @@ -66,5 +75,5 @@ class AircraftMutationResolver(BaseMutationResolver): input=data.organization, user_id=user_id, ) - aircraft = await models.Aircraft.update(db, id=id, data=update_data) - return Aircraft(**aircraft.as_dict()) + + return await self._do_update(db, id, update_data) diff --git a/src/graphql_schema/entities/resolvers/base.py b/src/graphql_schema/entities/resolvers/base.py index a64c677..2c25604 100644 --- a/src/graphql_schema/entities/resolvers/base.py +++ b/src/graphql_schema/entities/resolvers/base.py @@ -33,7 +33,6 @@ class BaseQueryResolver(BaseResolver): object_id: Optional[int] = None, order_by: Optional[list] = None, only_public: Optional[bool] = False, - *args, **kwargs, ): query = self.query_builder.get_simple_query(created_by_id=user_id, order_by=order_by, only_public=only_public) @@ -52,8 +51,8 @@ class BaseQueryResolver(BaseResolver): query = self.get_query(user_id, **kwargs) return await self._get_list(query) - async def get_one(self, id: int, user_id: Optional[int] = None, **kwargs) -> GQL_TYPE: - query = self.get_query(user_id, object_id=id, **kwargs) + async def get_one(self, user_id: Optional[int] = None, **kwargs) -> GQL_TYPE: + query = self.get_query(user_id, **kwargs) return await self._get_one(query) @@ -62,21 +61,31 @@ class BaseMutationResolver(BaseResolver): query = self.query_builder.get_simple_query(created_by_id=created_by_id).filter(self.model.id == id) return (await db.scalars(query)).one() - async def _do_create(self, data) -> GQL_TYPE: - async with get_session() as db: - model = await self.model.create(db, data=data) - return self.graphql_type(**model.as_dict()) + async def _do_create(self, db: AsyncSession, data: dict) -> GQL_TYPE: + model = await self.model.create(db, data=data) + return self.graphql_type(**model.as_dict()) - async def _do_update(self, db: AsyncSession, obj: models.BaseModel | dict, data: dict) -> GQL_TYPE: + async def _do_update(self, db: AsyncSession, obj: models.BaseModel | dict | int, data: dict) -> GQL_TYPE: update_where = {} if isinstance(obj, models.BaseModel): update_where['obj'] = obj - else: + elif isinstance(obj, dict): update_where['id'] = obj['id'] + else: + update_where['id'] = obj model = await self.model.update(db, data=data, **update_where) return self.graphql_type(**model.as_dict()) + async def create(self, context, data: BaseGraphqlInputType) -> GQL_TYPE: + input_data = data.to_dict() + + if hasattr(self.model, "created_by_id"): + input_data['created_by_id'] = context.user_id + + async with get_session() as db: + return await self._do_create(db, input_data) + async def update(self, id: int, data: BaseGraphqlInputType, user_id: int) -> GQL_TYPE: async with get_session() as db: item = await self._get_one(db, id, user_id) @@ -93,9 +102,3 @@ class BaseMutationResolver(BaseResolver): return self.graphql_type(**model.as_dict()) - async def create(self, data: dict, user_id: Optional[int] = None) -> GQL_TYPE: - input_data = {**data} - if hasattr(self.model, "created_by_id"): - input_data['created_by_id'] = user_id - - return await self._do_create(input_data) diff --git a/src/graphql_schema/entities/resolvers/flight.py b/src/graphql_schema/entities/resolvers/flight.py index 6469d1b..648e500 100644 --- a/src/graphql_schema/entities/resolvers/flight.py +++ b/src/graphql_schema/entities/resolvers/flight.py @@ -1,6 +1,6 @@ import asyncio from typing import List, Optional -from sqlalchemy import delete, insert +from sqlalchemy import delete, insert, select, func, text from sqlalchemy.ext.asyncio import AsyncSession from strawberry.file_uploads import Upload from background_jobs.elevation import add_terrain_elevation_to_flight @@ -8,9 +8,10 @@ from background_jobs.weather import download_weather from database import models from database.models import flight_has_copilot from database.transaction import get_session +from external.gpx_parser import GPXParser from graphql_schema.entities.helpers.combobox import handle_combobox_save -from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver -from graphql_schema.entities.types.mutation_input import EditFlightInput, TrackItemInput, ComboboxInput +from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver, GQL_TYPE +from graphql_schema.entities.types.mutation_input import EditFlightInput, TrackItemInput, ComboboxInput, CreateFlightInput from graphql_schema.entities.types.types import Flight from paths import FLIGHT_GPX_TRACK_PATH from utils.file import delete_file @@ -25,12 +26,20 @@ class FlightQueryResolver(BaseQueryResolver): self, user_id: Optional[int] = None, object_id: Optional[int] = None, + url_slug: Optional[str] = None, only_public: Optional[bool] = False, *args, **kwargs ): + filters = {} + if object_id: + filters['object_id'] = object_id + if url_slug: + filters['url_slug'] = url_slug + query = super().get_query( - user_id, object_id, + user_id, + **filters, order_by=[models.Flight.takeoff_datetime.desc()], only_public=only_public ) @@ -66,6 +75,90 @@ class FlightMutationResolver(BaseMutationResolver): def __init__(self): super().__init__(Flight, models.Flight) + async def get_airport_id_by_gps(self, gps_lat: float, gps_lng: float) -> Optional[int]: + async with get_session() as db: + query = select( + models.Airport, + func.coalesce((6371 * func.acos( + func.cos(func.radians(gps_lat)) * + func.cos(func.radians(models.Airport.gps_latitude)) * + func.cos(func.radians(models.Airport.gps_longitude) - func.radians(gps_lng)) + + func.sin(func.radians(gps_lat)) * + func.sin(func.radians(models.Airport.gps_latitude)) + )), 9999).label("distance") + ).filter(models.Airport.use_in_gpx_guess.is_(True)).order_by("distance").having(text("distance < 1")).limit(1) + + data = (await db.execute(query)).one_or_none() + + if data: + airport, distance = data + return airport.id + + return None + + async def extract_data_from_gpx(self, gpx_filename: str) -> dict: + data = GPXParser(f"{FLIGHT_GPX_TRACK_PATH}/{gpx_filename}") + + times, coordinates = await asyncio.gather( + data.get_times(), + data.get_coordinates() + ) + takeoff_airport_id, landing_airport_id = await asyncio.gather( + self.get_airport_id_by_gps(coordinates[0]['lat'], coordinates[0]['lng']), + self.get_airport_id_by_gps(coordinates[-1]['lat'], coordinates[-1]['lng']), + ) + + return { + "takeoff_airport_id": takeoff_airport_id, + "landing_airport_id": landing_airport_id, + "takeoff_datetime": times[0], + "landing_datetime": times[-1], + } + + async def create(self, context, input: CreateFlightInput) -> Flight: + data = input.to_dict() + user_id = context.user_id + + if input.gpx_track_file: + data['gpx_track_filename'] = await handle_upload_gpx(gpx_track=input.gpx_track_file, context=context) + data_from_gpx = await self.extract_data_from_gpx(data['gpx_track_filename']) + data.update(data_from_gpx) + else: + async with get_session() as db: + data.update({ + "takeoff_airport_id": await handle_combobox_save( + db, models.Airport, input.takeoff_airport, user_id, name_column="icao_code", + extra_data={"name": input.takeoff_airport.name} + ), + "landing_airport_id": await handle_combobox_save( + db, models.Airport, input.landing_airport, user_id, name_column="icao_code", + extra_data={"name": input.landing_airport.name} + ), + }) + + async with get_session() as db: + aircraft_id = await handle_aircraft_save(db, context.user_id, input.aircraft) + + data.update({ + "aircraft_id": aircraft_id, + "has_terrain_elevation": False, + "name": "", + "description": "", + "created_by_id": context.user_id + }) + flight = await self._do_create(db, data) + + context.background_tasks.add_task( + download_weather, + flight_id=flight.id, airport_id=flight.takeoff_airport_id, date_time=flight.takeoff_datetime, type_="takeoff" + ) + context.background_tasks.add_task( + download_weather, + flight_id=flight.id, airport_id=flight.landing_airport_id, date_time=flight.landing_datetime, type_="landing" + ) + + return flight + async def update(self, context, id: int, input: EditFlightInput) -> Flight: user_id = context.user_id async with get_session() as db: @@ -75,10 +168,11 @@ class FlightMutationResolver(BaseMutationResolver): data = input.to_dict() - if input.gpx_track is not None: - data['gpx_track_filename'] = await handle_upload_gpx(flight_data['gpx_track_filename'], input.gpx_track) - context.background_tasks.add_task( - add_terrain_elevation_to_flight, flight_id=id, gpx_filename=data['gpx_track_filename'] + if input.gpx_track_file is not None: + data['gpx_track_filename'] = await handle_upload_gpx( + gpx_track=input.gpx_track_file, + context=context, + original_gpx_filename=flight_data['gpx_track_filename'] ) async with get_session() as db: @@ -136,11 +230,14 @@ class FlightMutationResolver(BaseMutationResolver): return await self._do_update(db, flight_data, data) -async def handle_upload_gpx(original_gpx_filename: str, gpx_track: Upload): +async def handle_upload_gpx(gpx_track: Upload, context, original_gpx_filename: Optional[str] = None): if original_gpx_filename: delete_file(FLIGHT_GPX_TRACK_PATH + "/" + original_gpx_filename, silent=True) - return await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH) + filename = await handle_file_upload(gpx_track, FLIGHT_GPX_TRACK_PATH) + context.background_tasks.add_task(add_terrain_elevation_to_flight, flight_id=id, gpx_filename=filename) + + return filename async def handle_track_edit(db: AsyncSession, flight_id: int, track: List[TrackItemInput], user_id: int): diff --git a/src/graphql_schema/entities/resolvers/photo.py b/src/graphql_schema/entities/resolvers/photo.py index 8627244..2087058 100644 --- a/src/graphql_schema/entities/resolvers/photo.py +++ b/src/graphql_schema/entities/resolvers/photo.py @@ -1,5 +1,6 @@ import os import shutil +from time import time from typing import Optional from PIL import Image from pydantic import BaseModel @@ -91,29 +92,35 @@ class PhotoMutationResolver(BaseMutationResolver): async def upload(self, info, input: UploadPhotoInput) -> Photo: path = get_photo_basepath(input.flight_id) - filename = await handle_file_upload(input.photo, path) - exif_info = await parse_exif_info(path, filename) + img_name = await handle_file_upload(input.photo, path, uid_prefix=False, overwrite=False) - img = Image.open(f"{path}/{filename}") - photo = await PhotoMutationResolver().create( - user_id=info.context.user_id, - data={ - "flight_id": input.flight_id, - "name": input.name, - "filename": filename, - "width": img.width, - "height": img.height, - "description": input.description, - "exposed_at": exif_info.get("datetime_original"), - "gps_latitude": exif_info.get("gps_latitude"), - "gps_longitude": exif_info.get("gps_longitude"), - "gps_altitude": exif_info.get("gps_altitude"), - "is_flight_cover": False, - }, - ) + exif_info = await parse_exif_info(path, img_name) - info.context.background_tasks.add_task(resize_photo, path=path, filename=filename, photo_id=photo.id) - info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=filename) + img = Image.open(f"{path}/{img_name}") + filename, filename_ext = os.path.splitext(img_name) + + async with get_session() as db: + photo = await PhotoMutationResolver()._do_create( + db, + data={ + "flight_id": input.flight_id, + "name": input.name, + "filename": filename, + "filename_extension": filename_ext[1:], # nechci ukladat tecku na zacatku + "cache_key": int(time()), + "width": img.width, + "height": img.height, + "description": input.description, + "exposed_at": exif_info.get("datetime_original"), + "gps_latitude": exif_info.get("gps_latitude"), + "gps_longitude": exif_info.get("gps_longitude"), + "gps_altitude": exif_info.get("gps_altitude"), + "created_by_id": info.context.user_id, + }, + ) + + info.context.background_tasks.add_task(resize_photo, path=path, filename=img_name, photo_id=photo.id) + info.context.background_tasks.add_task(generate_thumbnail, path=path, filename=img_name) if exif_info.get("gps_latitude") and exif_info.get("gps_longitude"): info.context.background_tasks.add_task(add_terrain_elevation_to_photo, photo=photo) @@ -164,7 +171,8 @@ class PhotoMutationResolver(BaseMutationResolver): async with get_session() as db: return await self._do_update(db, obj={"id": id}, data={ "width": editor.img.width, - "height": editor.img.height + "height": editor.img.height, + "cache_key": int(time()) }) async def adjust(self, id: int, user_id: int, adjustment: AdjustmentInput, info): @@ -209,15 +217,25 @@ class PhotoMutationResolver(BaseMutationResolver): return await self._do_update(db, obj={"id": id}, data={ "width": editor.img.width, - "height": editor.img.height + "height": editor.img.height, + "cache_key": int(time()) }) async def delete(self, user_id: int, id: int) -> Photo: photo = await super().delete(user_id, id) base_path = get_photo_basepath(photo.flight_id) - delete_file(f"{base_path}/{photo.filename}", silent=True) - delete_file(f"{base_path}/_original_{photo.filename}", silent=True) - delete_file(f"{base_path}/thumbs/{photo.filename}", silent=True) + + files_to_delete = [ + photo.filename, + f"_original_{photo.filename}", + f"{photo.filename}.{photo.filename_extension}", + f"_original_{photo.filename}.{photo.filename_extension}", + f"thumbs/{photo.filename}", + f"thumbs/{photo.filename}.{photo.filename_extension}", + f"thumbs/{photo.filename}.webp", + ] + for filename in files_to_delete: + delete_file(f"{base_path}/{filename}", silent=True) return photo diff --git a/src/graphql_schema/entities/types/mutation_input.py b/src/graphql_schema/entities/types/mutation_input.py index bca69f7..277ab9b 100644 --- a/src/graphql_schema/entities/types/mutation_input.py +++ b/src/graphql_schema/entities/types/mutation_input.py @@ -102,7 +102,7 @@ class AdjustmentInput: "takeoff_weather_info_id", "landing_weather_info_id", "gpx_track_filename", "event_id" ], all_optional=True) class EditFlightInput(BaseGraphqlInputType): - gpx_track: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! + gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! track: Optional[List[TrackItemInput]] = None copilots: Optional[List[ComboboxInput]] = None aircraft: Optional[ComboboxInput] = None @@ -114,10 +114,11 @@ class EditFlightInput(BaseGraphqlInputType): @strawberry.input() class CreateFlightInput(BaseGraphqlInputType): aircraft: ComboboxInput - landing_airport: ComboboxInput - takeoff_airport: ComboboxInput - takeoff_datetime: datetime - landing_datetime: datetime + gpx_track_file: Optional[Upload] = None # TODO: poresit validaci uploadovaneho souboru! + landing_airport: Optional[ComboboxInput] = None + takeoff_airport: Optional[ComboboxInput] = None + takeoff_datetime: Optional[datetime] = None + landing_datetime: Optional[datetime] = None @strawberry.input() diff --git a/src/graphql_schema/entities/types/types.py b/src/graphql_schema/entities/types/types.py index a3fff48..449033b 100644 --- a/src/graphql_schema/entities/types/types.py +++ b/src/graphql_schema/entities/types/types.py @@ -128,8 +128,12 @@ class Flight: copilots: Optional[List[Copilot]] = strawberry.field(resolver=load_copilots) event: Optional[Event] = strawberry.field(resolver=load_event) aircraft: Aircraft = strawberry.field(resolver=lambda root: aircraft_dataloader.load(root.aircraft_id)) - takeoff_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.takeoff_airport_id)) - landing_airport: Airport = strawberry.field(resolver=lambda root: airport_dataloader.load(root.landing_airport_id)) + takeoff_airport: Optional[Airport] = strawberry.field( + resolver=lambda root: airport_dataloader.load(root.takeoff_airport_id) + ) + landing_airport: Optional[Airport] = strawberry.field( + resolver=lambda root: airport_dataloader.load(root.landing_airport_id) + ) title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id)) track: List[FlightTrack] = strawberry.field(resolver=lambda root: flight_track_dataloader.load(root.id)) takeoff_weather_info: Optional[WeatherInfo] = strawberry.field( diff --git a/src/paths.py b/src/paths.py index 2a7bf1e..1d0facf 100644 --- a/src/paths.py +++ b/src/paths.py @@ -1,6 +1,7 @@ import os from typing import Optional from config import API_URL +from logger import log PHOTO_BASE_PATH = "" AIRCRAFT_BASE_PATH = "" @@ -18,16 +19,27 @@ def get_public_url(filename: Optional[str]) -> str: def get_photo_url(root) -> str: - return get_public_url(f"photos/{root.flight_id}/{root.filename}") + filename = root.filename if not root.filename_extension else f"{root.filename}.{root.filename_extension}" + return get_public_url(f"photos/{root.flight_id}/{filename}?cache={root.cache_key}") def get_photo_thumbnail_url(root) -> str: - thumbnail = get_photo_basepath(root.flight_id) + "/thumbs/" + root.filename - if not os.path.isfile(thumbnail): - # TODO: logovani - return get_public_url(f"photos/{root.flight_id}/{root.filename}") + filename = root.filename if not root.filename_extension else f"{root.filename}.{root.filename_extension}" + thumbnail_names = [ + f"thumbs/{root.filename}.webp", + f"thumbs/{filename}", + filename, + ] - return get_public_url(f"photos/{root.flight_id}/thumbs/{root.filename}") + for thumbnail in thumbnail_names: + if os.path.isfile(f"{get_photo_basepath(root.flight_id)}/{thumbnail}"): + # TODO: logovani + return get_public_url(f"photos/{root.flight_id}/{thumbnail}?cache={root.cache_key}") + else: + log.warning(f"Missing thumbnail {thumbnail} in flight ID={root.flight_id}") + + # TODO: doplnit chybejici nahled, tohle by se ale nikdy nemelo stat! Vzdy musi existovat alespon originalni fotka + return get_public_url(f"photos/missing-thumbnail.webp") def get_avatar_url(user) -> str: diff --git a/src/scripts/migrate_photos_to_webp.py b/src/scripts/migrate_photos_to_webp.py new file mode 100644 index 0000000..e1fad8d --- /dev/null +++ b/src/scripts/migrate_photos_to_webp.py @@ -0,0 +1,50 @@ +import asyncio +import os.path +import time + +import sys +from PIL import Image +from PIL.Image import DecompressionBombWarning +from sqlalchemy import select + +sys.path.insert(0, "/app/src") +from paths import get_photo_basepath # noqa +from database import async_session, models # noqa + + +async def migrate_photos_to_webp(): + async with async_session() as session: + photos = (await session.scalars( + select(models.Photo).filter(models.Photo.filename_extension == "") + )).all() + + for photo in photos: + path = get_photo_basepath(photo.flight_id) + + try: + img = Image.open(f"{path}/{photo.filename}") + + filename, ext = os.path.splitext(photo.filename) + new_filename = filename[37:] # vyhodim uuid z filename + + img.save(f"{path}/{new_filename}.webp", format="webp") + + thumb = Image.open(f"{path}/thumbs/{photo.filename}") + thumb.save(f"{path}/thumbs/{new_filename}.webp", format="webp") + + await models.Photo.update(session, { + "filename": new_filename, + "cache_key": int(time.time()), + "filename_ext": "webp", + }, obj=photo) + print(path, photo.filename, "=>", new_filename, "OK") + except (Exception, DecompressionBombWarning) as e: + print(path, photo.filename, e) + + await session.flush() + await session.commit() + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(migrate_photos_to_webp()) diff --git a/src/utils/image.py b/src/utils/image.py index 43b3619..fe6c22b 100644 --- a/src/utils/image.py +++ b/src/utils/image.py @@ -127,11 +127,12 @@ class PhotoEditor: return img_io def write_to_file( - self, quality: int = 90, dest_path: Optional[str] = None, dest_filename: Optional[str] = None + self, quality: int = 90, dest_path: Optional[str] = None, dest_filename: Optional[str] = None, + format_: Optional[str] = "JPEG" ) -> str: check_directories(dest_path or self.path) dest = f"{dest_path or self.path}/{dest_filename or self.filename}" - self.img.save(dest, 'JPEG', quality=quality) + self.img.save(dest, format_, quality=quality) return dest diff --git a/src/utils/upload.py b/src/utils/upload.py index 3d6e12c..2b0bdcc 100644 --- a/src/utils/upload.py +++ b/src/utils/upload.py @@ -1,16 +1,27 @@ +import os +import re import uuid from strawberry.file_uploads import Upload from utils.file import check_directories -async def handle_file_upload(file: Upload, path: str) -> str: +async def handle_file_upload(file: Upload, path: str, filename_maxlength: int = 64, uid_prefix: bool = True, overwrite: bool = True) -> str: check_directories(path) - filename = f"{uuid.uuid4()}-{file.filename}" + prefix = f"{uuid.uuid4()}-" if uid_prefix else "" + filename = f"{prefix}{file.filename}"[-1 * filename_maxlength:] + + # sanitize filename + filename = re.sub('[^\w_. -]', '', filename).replace(" ", "-") + + target_path = f"{path}/{filename}" + + if os.path.exists(target_path) and not overwrite: + raise FileExistsError(f"File {filename} already exists") content = await file.read() - image = open(path + "/" + filename, "wb") + image = open(target_path, "wb") image.write(content) image.close()