Moznost nahravat fotky copilotem

This commit is contained in:
Michal Kváček
2024-03-07 06:45:00 +01:00
parent c0ae73440d
commit 400ad8b75a
15 changed files with 242 additions and 44 deletions
@@ -0,0 +1,53 @@
"""add flight_has_copilot.token
Revision ID: 7dd139549851
Revises: a410455fb3bb
Create Date: 2024-03-02 15:02:26.594830
"""
import random
import string
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = '7dd139549851'
down_revision = 'a410455fb3bb'
branch_labels = None
depends_on = None
def rand_str():
alphabet = string.ascii_letters + string.digits
return ''.join([random.choice(alphabet) for _ in range(64)])
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('flight_has_copilot', sa.Column('token', sa.String(length=64), nullable=True))
conn = op.get_bind()
data = conn.execute(text("SELECT flight_id, copilot_id FROM flight_has_copilot"))
for flight_id, copilot_id in data:
print(flight_id, copilot_id)
conn.execute(text(
f"UPDATE flight_has_copilot SET token='{rand_str()}'"
f" WHERE flight_id={flight_id} AND copilot_id={copilot_id}"
))
op.create_unique_constraint(None, 'flight_has_copilot', ['token'])
op.add_column('photo', sa.Column('created_by_copilot_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'photo', 'copilot', ['created_by_copilot_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('photo_ibfk_5', 'photo', type_='foreignkey')
op.drop_column('photo', 'created_by_copilot_id')
op.drop_constraint('token', 'flight_has_copilot', type_='unique')
op.drop_column('flight_has_copilot', 'token')
# ### end Alembic commands ###
+2
View File
@@ -55,6 +55,7 @@ flight_has_copilot = Table(
BaseModel.metadata,
Column("flight_id", ForeignKey("flight.id"), primary_key=True),
Column("copilot_id", ForeignKey("copilot.id"), primary_key=True),
Column("token", String(64), unique=True)
)
copilot_has_photo = Table(
@@ -141,6 +142,7 @@ class Photo(BaseModel):
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'))
created_by_copilot_id: Mapped[int] = mapped_column(Integer, ForeignKey('copilot.id'), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
flight: Mapped['Flight'] = relationship(foreign_keys=[flight_id])
+6 -2
View File
@@ -3,6 +3,10 @@ from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED
def raise_unauthorized():
raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized")
def allow_public(func):
@wraps(func)
async def decorator(*args, **kwargs):
@@ -10,7 +14,7 @@ def allow_public(func):
user_id = kwargs['info'].context.user_id
public = kwargs.get('public')
if not user_id and not public:
raise HTTPException(HTTP_401_UNAUTHORIZED)
raise_unauthorized()
return await func(*args, **kwargs)
@@ -24,7 +28,7 @@ def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_u
if 'info' in kwargs:
if not kwargs['info'].context.user_id:
if raise_when_unauthorized:
raise HTTPException(HTTP_401_UNAUTHORIZED, "Not authorized")
raise_unauthorized()
else:
return return_value_unauthorized
return await func(*args, **kwargs)
+50 -19
View File
@@ -1,5 +1,6 @@
from collections import defaultdict
from typing import Type, List, Optional
from logger import log
from database import models, async_session
from database.query_builder import QueryBuilder
@@ -9,7 +10,7 @@ class BaseDataloader:
self,
model: Type[models.BaseModel],
relationship_column, filters: Optional[list] = None
) -> None:
):
super().__init__()
self.model = model
self.query_builder = QueryBuilder(self.model)
@@ -43,6 +44,7 @@ class MultiModelsDataloader(BaseDataloader):
model: Type[models.BaseModel],
relationship_column=None,
filters: Optional[list] = None,
extra_select: Optional[list] = None,
extra_join: Optional[list] = None,
order_by: Optional[list] = None,
):
@@ -52,31 +54,60 @@ class MultiModelsDataloader(BaseDataloader):
extra_join = []
self.extra_join = extra_join
if extra_select is None:
extra_select = []
self.extra_select = extra_select
if order_by is None:
order_by = [model.id.desc()] # defaultne radit od nejnovejsich zaznamu
self.order_by = order_by
async def load(self, ids: List[int]):
async with async_session() as db:
query = (
self.query_builder.get_simple_query(
extra_select=[self.relationship_column],
order_by=self.order_by
)
.filter(self.relationship_column.in_(set(ids)))
.filter(*self.filters)
def get_query(self, ids: list[int]):
query = (
self.query_builder.get_simple_query(
extra_select=[self.relationship_column] + self.extra_select,
order_by=self.order_by
)
.filter(self.relationship_column.in_(set(ids)))
.filter(*self.filters)
)
for joined_table in self.extra_join:
query = query.join(joined_table)
for joined_table in self.extra_join:
query = query.join(joined_table)
if self.filters:
query = query.filter(*self.filters)
if self.filters:
query = query.filter(*self.filters)
return query
async def load(self, ids: List[int]):
query = self.get_query(ids)
async with async_session() as db:
data = (await db.execute(query)).all()
result_data = defaultdict(list)
for item, rel_id in data:
result_data[rel_id].append(item)
result_data = self.process_data(data)
return [result_data[id_] for id_ in ids]
def process_data(self, data):
result_data = defaultdict(list)
for row in data:
item, rel_id = row[0:2]
extra = row[2:]
if extra:
log.warning(f"Override function process_data, extra params={extra} are going to be discarded!")
result_data[rel_id].append(item)
return result_data
class FlightCopilotDataloader(MultiModelsDataloader):
def process_data(self, data):
result_data = defaultdict(list)
for row in data:
item, rel_id = row[0:2]
token = row[2]
item.token = token
result_data[rel_id].append(item)
return result_data
@@ -1,6 +1,6 @@
from strawberry.dataloader import DataLoader
from database import models
from graphql_schema.dataloaders.base import MultiModelsDataloader
from graphql_schema.dataloaders.base import MultiModelsDataloader, FlightCopilotDataloader
aircrafts_from_organization_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
@@ -12,9 +12,14 @@ aircrafts_from_organization_dataloader = DataLoader(
)
flight_copilots_dataloader = DataLoader(
load_fn=MultiModelsDataloader(
load_fn=FlightCopilotDataloader(
models.Copilot,
relationship_column=models.Flight.id,
extra_select=[models.flight_has_copilot.c.token],
filters=[
models.flight_has_copilot.c.flight_id == models.Flight.id,
models.flight_has_copilot.c.copilot_id == models.Copilot.id,
],
extra_join=[models.Copilot.flights]).load,
cache=False)
+14 -1
View File
@@ -1,5 +1,6 @@
from typing import List, Optional
import strawberry
from graphql import GraphQLError
from strawberry.types import Info
from database import models
from decorators.error_logging import error_logging
@@ -27,12 +28,24 @@ class CopilotQueries:
id: Optional[int] = None,
url_slug: Optional[str] = None,
pilot_username: Optional[str] = None,
upload_token: Optional[str] = None,
upload_flight_slug: Optional[str] = None,
public: Optional[bool] = False
) -> Copilot:
filter_params = get_detail_filters(id, url_slug)
filter_params = {}
if id:
filter_params['object_id'] = id
if url_slug is not None:
filter_params['url_slug'] = url_slug
if upload_token and upload_flight_slug:
filter_params['upload_token'] = upload_token
filter_params['upload_flight_slug'] = upload_flight_slug
if pilot_username:
filter_params['pilot_username'] = pilot_username
if not filter_params:
raise GraphQLError(f"Invalid identification supplied: {filter_params}")
return await CopilotQueryResolver().get_one(
user_id=info.context.user_id,
only_public=public,
+2
View File
@@ -1,5 +1,7 @@
from typing import Optional
import strawberry
from graphql import GraphQLError
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.flight import FlightMutationResolver, FlightQueryResolver
+15 -6
View File
@@ -1,7 +1,7 @@
from typing import List, Optional
import strawberry
from database import models
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.endpoints import authenticated_user_only, allow_public, raise_unauthorized
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver
@@ -18,6 +18,7 @@ class PhotoQueries:
root, info,
flight_id: Optional[int] = None,
copilot_id: Optional[int] = None,
uploaded_by_copilot_id: Optional[int] = None,
point_of_interest_id: Optional[int] = None,
aircraft_id: Optional[int] = None,
public: Optional[bool] = False,
@@ -27,6 +28,7 @@ class PhotoQueries:
flight_id=flight_id,
user_id=info.context.user_id,
copilot_id=copilot_id,
uploaded_by_copilot_id=uploaded_by_copilot_id,
aircraft_id=aircraft_id,
point_of_interest_id=point_of_interest_id,
order_by=[models.Photo.exposed_at]
@@ -47,8 +49,10 @@ class PhotoQueries:
class PhotoMutation:
@strawberry.mutation
@error_logging
@authenticated_user_only()
async def upload_photo(self, info, input: UploadPhotoInput) -> Photo:
if info.context.user_id is None and not input.copilot_upload_token:
raise_unauthorized()
return await PhotoMutationResolver().upload(info, input)
@strawberry.mutation()
@@ -59,11 +63,14 @@ class PhotoMutation:
@strawberry.mutation()
@error_logging
@authenticated_user_only()
async def change_orientation(self, info, id: int, direction: str) -> Photo:
async def change_orientation(self, info, id: int, direction: str, copilot_upload_token: Optional[str] = None) -> Photo:
if info.context.user_id is None and not copilot_upload_token:
raise_unauthorized()
return await PhotoMutationResolver().change_orientation(
id=id,
user_id=info.context.user_id,
copilot_upload_token=copilot_upload_token,
direction=direction,
info=info
)
@@ -76,6 +83,8 @@ class PhotoMutation:
@strawberry.mutation()
@error_logging
@authenticated_user_only()
async def delete_photo(self, info, id: int) -> Photo:
async def delete_photo(self, info, id: int, copilot_upload_token: Optional[str] = None) -> Photo:
if info.context.user_id is None and not copilot_upload_token:
raise_unauthorized()
return await PhotoMutationResolver().delete(user_id=info.context.user_id, id=id)
@@ -120,7 +120,7 @@ class BaseMutationResolver(BaseResolver):
item = await self._get_one(db, id, user_id)
return await self._do_update(db, item, data.to_dict())
async def delete(self, user_id: int, id: int) -> GQL_TYPE:
async def delete(self, user_id: int, id: int, **kwargs) -> GQL_TYPE:
async with get_session() as db:
model = await self._get_one(db, id, user_id)
@@ -1,4 +1,7 @@
from typing import Optional
from sqlalchemy import and_
from database import models
from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.types.types import Copilot
@@ -17,6 +20,8 @@ class CopilotQueryResolver(BaseQueryResolver):
**kwargs
):
pilot_username = kwargs.pop("pilot_username", None)
upload_token = kwargs.pop("upload_token", None)
upload_flight_slug = kwargs.pop("upload_flight_slug", None)
query = super().get_query(
user_id=user_id, object_id=object_id, order_by=order_by,
@@ -31,4 +36,14 @@ class CopilotQueryResolver(BaseQueryResolver):
.filter(models.User.public_username == pilot_username)
)
if upload_token and upload_flight_slug:
query = (
query.join(models.Copilot.flights)
.filter(and_(
models.Flight.url_slug == upload_flight_slug,
models.Flight.created_by_id == models.User.id
))
.filter(models.flight_has_copilot.c.token == upload_token)
)
return query
@@ -17,6 +17,7 @@ from graphql_schema.entities.types.mutation_input import (
from graphql_schema.entities.types.types import Flight
from paths import FLIGHT_GPX_TRACK_PATH
from utils.file import delete_file
from utils.str_utils import random_str
from utils.upload import handle_file_upload
@@ -232,7 +233,11 @@ class FlightMutationResolver(BaseMutationResolver):
])
for copilot_id in copilots:
await db.execute(insert(flight_has_copilot).values(flight_id=flight_id, copilot_id=copilot_id))
await db.execute(insert(flight_has_copilot).values(
flight_id=flight_id,
copilot_id=copilot_id,
token=random_str(64)
))
return await self._do_update(db, flight_data, data)
+56 -12
View File
@@ -4,7 +4,7 @@ from time import time
from typing import Optional
from PIL import Image
from pydantic import BaseModel
from sqlalchemy import delete, insert
from sqlalchemy import delete, insert, select
from background_jobs.elevation import add_terrain_elevation_to_photo
from background_jobs.photo import generate_thumbnail, resize_photo
from database import models
@@ -54,6 +54,9 @@ class PhotoQueryResolver(BaseQueryResolver):
.filter(models.copilot_has_photo.c.copilot_id == kwargs['copilot_id'])
)
if kwargs.get("uploaded_by_copilot_id"):
query = query.filter(models.Photo.created_by_copilot_id == kwargs['uploaded_by_copilot_id'])
return query
@@ -76,9 +79,13 @@ class PhotoMutationResolver(BaseMutationResolver):
return original_filename
async def _get_photo_details(self, id: int, user_id: int):
async def _get_photo_details(self, id: int, user_id: int, copilot_token: Optional[str]):
async with get_session() as db:
photo = await self._get_one(db, id, created_by_id=user_id)
if not copilot_token:
photo = await self._get_one(db, id, created_by_id=user_id)
else:
photo = await self._get_photo_info_by_copilot_token(db, photo_id=id, copilot_token=copilot_token)
flight_id = photo.flight_id
filename = photo.filename + "." + photo.filename_extension
@@ -87,17 +94,48 @@ class PhotoMutationResolver(BaseMutationResolver):
flight_id=flight_id,
path=path,
filename=filename,
original_filename=self._copy_original(path, filename)
original_filename=self._copy_original(path, filename) # TODO: tohle nedelat v getteru!
)
@staticmethod
async def _get_photo_info_by_copilot_token(db, photo_id: int, copilot_token: Optional[str]):
if not copilot_token:
return
return (
await db.scalars(
select(models.Photo)
.join(models.Photo.flight)
.join(models.flight_has_copilot)
.filter(models.flight_has_copilot.c.token == copilot_token)
.filter(models.Photo.id == photo_id)
)
).one()
@staticmethod
async def _validate_copilot_token(flight_id: int, token: str) -> tuple[int, int]:
async with get_session() as db:
query = (
select(models.Copilot.created_by_id, models.Copilot.id)
.join(models.flight_has_copilot)
.filter(models.flight_has_copilot.c.flight_id == flight_id)
.filter(models.flight_has_copilot.c.token == token)
)
return (await db.execute(query)).fetchone()
async def upload(self, info, input: UploadPhotoInput) -> Photo:
created_by_id = info.context.user_id
copilot_id = None
if input.copilot_upload_token:
created_by_id, copilot_id = await self._validate_copilot_token(input.flight_id, input.copilot_upload_token)
path = get_photo_basepath(input.flight_id)
img_name = await handle_file_upload(input.photo, path, uid_prefix=False, overwrite=False)
exif_info = await parse_exif_info(path, img_name)
img = Image.open(f"{path}/{img_name}")
filename, filename_ext = os.path.splitext(img_name)
exif_info = await parse_exif_info(path, img_name)
async with get_session() as db:
photo = await PhotoMutationResolver()._do_create(
@@ -115,7 +153,8 @@ class PhotoMutationResolver(BaseMutationResolver):
"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,
"created_by_id": created_by_id,
"created_by_copilot_id": copilot_id
},
)
@@ -148,8 +187,10 @@ class PhotoMutationResolver(BaseMutationResolver):
return await self._do_update(db, obj=photo, data=data)
async def change_orientation(self, id: int, user_id: int, direction: str, info):
photo = await self._get_photo_details(id, user_id)
async def change_orientation(
self, id: int, user_id: int, direction: str, copilot_upload_token: Optional[str], info
):
photo = await self._get_photo_details(id, user_id, copilot_upload_token)
degrees_map = {
"clockwise": 90,
@@ -176,7 +217,7 @@ class PhotoMutationResolver(BaseMutationResolver):
})
async def adjust(self, id: int, user_id: int, adjustment: AdjustmentInput, info):
photo = await self._get_photo_details(id, user_id)
photo = await self._get_photo_details(id, user_id, copilot_token=None)
editor = (
PhotoEditor(photo.path, photo.original_filename)
.adjust(
@@ -221,9 +262,12 @@ class PhotoMutationResolver(BaseMutationResolver):
"cache_key": int(time())
})
async def delete(self, user_id: int, id: int) -> Photo:
photo = await super().delete(user_id, id)
async def delete(self, user_id: int, id: int, **kwargs) -> Photo:
copilot_upload_token = kwargs.get("copilot_upload_token")
await self._get_photo_details(id, user_id, copilot_upload_token)
# ^^ pokud neexistuje, vyhodi vyjimku
photo = await super().delete(user_id, id)
base_path = get_photo_basepath(photo.flight_id)
files_to_delete = [
@@ -61,6 +61,7 @@ class UploadPhotoInput:
name: Optional[str] = None
description: Optional[str] = None
point_of_interest: Optional[ComboboxInput] = None
copilot_upload_token: Optional[str] = None
@strawberry.input
@@ -170,9 +170,16 @@ class Copilot:
return await dataloader.load(root.id)
def resolve_copilot_token(root, info):
return getattr(root, "token")
flights: List[Flight] = strawberry.field(resolver=resolve_flights)
photos: List[Photo] = strawberry.field(resolver=lambda root: photo_copilots_dataloader.load(root.id))
title_photo: Optional[Photo] = strawberry.field(resolver=lambda root: photo_dataloader.load(root.title_photo_id))
token: Optional[str] = strawberry.field(
permission_classes=[IsAuthenticated],
resolver=resolve_copilot_token
)
@strawberry_sqlalchemy_type(models.Aircraft)
+7
View File
@@ -0,0 +1,7 @@
import random
import string
def random_str(length: int) -> str:
alphabet = string.ascii_letters + string.digits
return ''.join([random.choice(alphabet) for _ in range(length)])