Strankovani

This commit is contained in:
Michal Kváček
2023-12-12 23:41:44 +01:00
parent f535d2866c
commit f4d709d621
15 changed files with 154 additions and 85 deletions
-1
View File
@@ -3,7 +3,6 @@ import os
APP_DEBUG = True APP_DEBUG = True
GRAPHIQL = True GRAPHIQL = True
ACCESS_TOKEN_VALIDITY_MINUTES = 20
REFRESH_TOKEN_VALIDITY_DAYS = 30 REFRESH_TOKEN_VALIDITY_DAYS = 30
API_URL = os.environ.get("API_URL") or "http://localhost:8000" API_URL = os.environ.get("API_URL") or "http://localhost:8000"
+12 -2
View File
@@ -3,8 +3,18 @@ from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED from starlette.status import HTTP_401_UNAUTHORIZED
def public_endpoint(func): def allow_public(func):
pass @wraps(func)
async def decorator(*args, **kwargs):
if 'info' in kwargs:
user_id = kwargs['info'].context.user_id
public = kwargs.get('public')
if not user_id and not public:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await func(*args, **kwargs)
return decorator
def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_unauthorized=None): def authenticated_user_only(raise_when_unauthorized: bool = True, return_value_unauthorized=None):
+1 -2
View File
@@ -41,8 +41,7 @@ class LoginEndpoint(AuthEndpoint):
return { return {
"user": user, "user": user,
"access_token": access_token, "access_token": access_token
"access_token_validity": self.access_security.access_expires_delta.total_seconds(),
} }
@@ -21,4 +21,4 @@ flight_dataloader = create_dataloader(models.Flight)
photo_adjustment_dataloader = create_dataloader( photo_adjustment_dataloader = create_dataloader(
models.PhotoAdjustment, relationship_column=models.PhotoAdjustment.photo_id models.PhotoAdjustment, relationship_column=models.PhotoAdjustment.photo_id
) )
photo_dataloader = create_dataloader(models.Photo) photo_dataloader = create_dataloader(models.Photo)
+17 -11
View File
@@ -1,10 +1,8 @@
from typing import List, Optional from typing import Optional
import strawberry import strawberry
from fastapi import HTTPException from decorators.endpoints import authenticated_user_only, allow_public
from starlette.status import HTTP_401_UNAUTHORIZED
from decorators.endpoints import authenticated_user_only
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from .helpers.pagination import get_pagination_window, PaginationWindow
from .resolvers.aircraft import AircraftMutationResolver, AircraftQueryResolver from .resolvers.aircraft import AircraftMutationResolver, AircraftQueryResolver
from graphql_schema.entities.types.mutation_input import CreateAircraftInput, EditAircraftInput from graphql_schema.entities.types.mutation_input import CreateAircraftInput, EditAircraftInput
from graphql_schema.entities.types.types import Aircraft from graphql_schema.entities.types.types import Aircraft
@@ -15,20 +13,28 @@ class AircraftQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
@authenticated_user_only() @authenticated_user_only()
async def aircrafts(root, info) -> List[Aircraft]: async def aircrafts(root, info, limit: int, offset: int = 0) -> PaginationWindow[Aircraft]:
return await AircraftQueryResolver().get_list( query = AircraftQueryResolver().get_query(
info.context.user_id, info.context.user_id,
organization_ids=info.context.organization_ids organization_ids=info.context.organization_ids
) )
return await get_pagination_window(
query=query,
item_type=Aircraft,
limit=limit,
offset=offset
)
@strawberry.field() @strawberry.field()
@error_logging @error_logging
@allow_public
async def aircraft(root, info, id: int, public: Optional[bool] = False) -> Aircraft: async def aircraft(root, info, id: int, public: Optional[bool] = False) -> Aircraft:
if not info.context.user_id and not public:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await AircraftQueryResolver().get_one( return await AircraftQueryResolver().get_one(
id, user_id=info.context.user_id, organization_ids=info.context.organization_ids, public=public id,
user_id=info.context.user_id,
organization_ids=info.context.organization_ids,
public=public
) )
+2 -6
View File
@@ -1,11 +1,9 @@
from typing import List, Optional from typing import List, Optional
import strawberry import strawberry
from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED
from strawberry.types import Info from strawberry.types import Info
from database import models from database import models
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public
from graphql_schema.entities.resolvers.base import BaseMutationResolver from graphql_schema.entities.resolvers.base import BaseMutationResolver
from graphql_schema.entities.resolvers.copilot import CopilotQueryResolver from graphql_schema.entities.resolvers.copilot import CopilotQueryResolver
from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput from graphql_schema.entities.types.mutation_input import CreateCopilotInput, EditCopilotInput
@@ -22,10 +20,8 @@ class CopilotQueries:
@strawberry.field() @strawberry.field()
@error_logging @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: int, pilot_username: Optional[str] = None) -> Copilot:
if not info.context.user_id and not pilot_username:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await CopilotQueryResolver().get_one( return await CopilotQueryResolver().get_one(
id, id,
user_id=info.context.user_id, user_id=info.context.user_id,
+24 -13
View File
@@ -1,10 +1,9 @@
from typing import List, Optional from typing import List, Optional
import strawberry import strawberry
from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from graphql_schema.entities.helpers.pagination import PaginationWindow, get_pagination_window
from graphql_schema.entities.resolvers.base import BaseMutationResolver from graphql_schema.entities.resolvers.base import BaseMutationResolver
from graphql_schema.entities.resolvers.event import EventQueryResolver from graphql_schema.entities.resolvers.event import EventQueryResolver
from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput from graphql_schema.entities.types.mutation_input import CreateEventInput, EditEventInput
@@ -15,25 +14,37 @@ from graphql_schema.entities.types.types import Event
class EventQueries: class EventQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
async def events(root, info, username: Optional[str] = None) -> List[Event]: @allow_public
if not info.context.user_id and not username: async def events(
raise HTTPException(HTTP_401_UNAUTHORIZED) root,
info,
return await EventQueryResolver().get_list( limit: int,
offset: int = 0,
username: Optional[str] = None,
public: Optional[bool] = False,
) -> PaginationWindow[Event]:
query = EventQueryResolver().get_query(
info.context.user_id, info.context.user_id,
username=username, username=username,
order_by=[models.Event.date_from.desc(), models.Event.id.desc()] order_by=[models.Event.date_from.desc(), models.Event.id.desc()],
public=public
)
return await get_pagination_window(
query=query,
item_type=Event,
limit=limit,
offset=offset
) )
@strawberry.field() @strawberry.field()
@error_logging @error_logging
async def event(root, info, id: int, username: Optional[str] = None) -> Event: @allow_public
if not info.context.user_id and not username: async def event(root, info, id: int, username: Optional[str] = None, public: Optional[bool] = False) -> Event:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await EventQueryResolver().get_one( return await EventQueryResolver().get_one(
id, id,
username=username, username=username,
public=public,
user_id=info.context.user_id user_id=info.context.user_id
) )
+23 -15
View File
@@ -1,17 +1,16 @@
import asyncio import asyncio
from typing import List, Optional from typing import Optional
import strawberry import strawberry
from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED
from background_jobs.weather import download_weather from background_jobs.weather import download_weather
from database import models from database import models
from decorators.endpoints import authenticated_user_only
from decorators.error_logging import error_logging
from database.transaction import get_session from database.transaction import get_session
from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.flight import handle_aircraft_save, FlightMutationResolver, FlightQueryResolver from graphql_schema.entities.resolvers.flight import handle_aircraft_save, FlightMutationResolver, FlightQueryResolver
from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput from graphql_schema.entities.types.mutation_input import EditFlightInput, CreateFlightInput
from .helpers.combobox import handle_combobox_save
from graphql_schema.entities.types.types import Flight from graphql_schema.entities.types.types import Flight
from .helpers.combobox import handle_combobox_save
from .helpers.pagination import PaginationWindow, get_pagination_window
@strawberry.type @strawberry.type
@@ -19,34 +18,43 @@ class FlightQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
@allow_public
async def flights( async def flights(
root, info, root, info,
limit: int,
offset: int = 0,
username: Optional[str] = None, username: Optional[str] = None,
public: Optional[bool] = False, public: Optional[bool] = False,
copilot_id: Optional[int] = None, copilot_id: Optional[int] = None,
point_of_interest_id: Optional[int] = None, point_of_interest_id: Optional[int] = None,
aircraft_id: Optional[int] = None, aircraft_id: Optional[int] = None,
) -> List[Flight]: ) -> PaginationWindow[Flight]:
if not info.context.user_id and not public: query = FlightQueryResolver().get_query(
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await FlightQueryResolver().get_list(
user_id=info.context.user_id, user_id=info.context.user_id,
username=username, username=username,
only_public=public, only_public=public,
copilot_id=copilot_id, copilot_id=copilot_id,
aircraft_id=aircraft_id, aircraft_id=aircraft_id,
point_of_interest_id=point_of_interest_id point_of_interest_id=point_of_interest_id
)
return await get_pagination_window(
query=query,
item_type=Flight,
limit=limit,
offset=offset,
) )
@strawberry.field() @strawberry.field()
@error_logging @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: int, username: Optional[str] = None, public: Optional[bool] = False) -> Flight:
if not info.context.user_id and not public: return await FlightQueryResolver().get_one(
raise HTTPException(HTTP_401_UNAUTHORIZED) id,
user_id=info.context.user_id,
return await FlightQueryResolver().get_one(id, user_id=info.context.user_id, username=username, public=public) username=username,
public=public
)
@strawberry.type @strawberry.type
@@ -0,0 +1,43 @@
from typing import TypeVar, Generic, List
import strawberry
from sqlalchemy import func, Select
from database.transaction import get_session
Item = TypeVar("Item")
@strawberry.type
class PaginationWindow(Generic[Item]):
items: List[Item] = strawberry.field(
description="The list of items in this pagination window."
)
total_items_count: int = strawberry.field(
description="Total number of items in the filtered dataset."
)
async def get_pagination_window(
query: Select,
item_type: type,
limit: int,
offset: int = 0,
) -> PaginationWindow:
if limit <= 0:
raise Exception(f"limit ({limit}) must be > 0")
async with get_session() as db:
cnt_query = query.with_only_columns(func.count())
total_items_count = (await db.scalars(cnt_query)).one()
# if offset != 0 and not 0 <= offset < total_items_count:
# raise Exception(f"offset ({offset}) is out of range " f"(0-{total_items_count - 1})")
async with get_session() as db:
data = (await db.scalars(query.limit(limit).offset(offset))).all()
dataset = [item_type(**i.as_dict()) for i in data]
return PaginationWindow(
items=dataset,
total_items_count=total_items_count
)
+5 -3
View File
@@ -1,7 +1,7 @@
from typing import List, Optional from typing import List, Optional
import strawberry import strawberry
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from graphql_schema.entities.resolvers.base import BaseQueryResolver from graphql_schema.entities.resolvers.base import BaseQueryResolver
from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver from graphql_schema.entities.resolvers.photo import PhotoMutationResolver, PhotoQueryResolver
@@ -13,6 +13,7 @@ from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadP
class PhotoQueries: class PhotoQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
@allow_public
async def photos( async def photos(
root, info, root, info,
flight_id: Optional[int] = None, flight_id: Optional[int] = None,
@@ -32,8 +33,9 @@ class PhotoQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
async def photo(root, info, id: int) -> Photo: @allow_public
return await BaseQueryResolver(Photo, models.Photo).get_one(id, user_id=info.context.user_id) 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)
@strawberry.type @strawberry.type
+17 -13
View File
@@ -1,12 +1,11 @@
from typing import List from typing import List
import strawberry import strawberry
from fastapi import HTTPException
from starlette.status import HTTP_401_UNAUTHORIZED
from database import models from database import models
from decorators.endpoints import authenticated_user_only from decorators.endpoints import authenticated_user_only, allow_public
from database.transaction import get_session from database.transaction import get_session
from decorators.error_logging import error_logging from decorators.error_logging import error_logging
from graphql_schema.entities.helpers.combobox import handle_combobox_save from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.helpers.pagination import get_pagination_window, PaginationWindow
from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver from graphql_schema.entities.resolvers.base import BaseQueryResolver, BaseMutationResolver
from graphql_schema.entities.types.types import PointOfInterest from graphql_schema.entities.types.types import PointOfInterest
from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput from graphql_schema.entities.types.mutation_input import CreatePointOfInterestInput, EditPointOfInterestInput
@@ -16,20 +15,25 @@ from graphql_schema.entities.types.mutation_input import CreatePointOfInterestIn
class PointOfInterestQueries: class PointOfInterestQueries:
@strawberry.field() @strawberry.field()
@error_logging @error_logging
async def points_of_interest(root, info, public: bool = False) -> List[PointOfInterest]: @allow_public
if not info.context.user_id and not public: async def points_of_interest(
raise HTTPException(HTTP_401_UNAUTHORIZED) root, info,
limit: int, offset: int = 0,
return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_list( public: bool = False
info.context.user_id, ) -> PaginationWindow[PointOfInterest]:
only_public=public query = BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_query(
info.context.user_id, only_public=public
)
return await get_pagination_window(
query=query,
item_type=PointOfInterest,
limit=limit,
offset=offset
) )
@strawberry.field() @strawberry.field()
@allow_public
async def point_of_interest(root, info, id: int, public: bool = False) -> PointOfInterest: async def point_of_interest(root, info, id: int, public: bool = False) -> PointOfInterest:
if not info.context.user_id and not public:
raise HTTPException(HTTP_401_UNAUTHORIZED)
return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_one( return await BaseQueryResolver(PointOfInterest, models.PointOfInterest).get_one(
id, info.context.user_id, id, info.context.user_id,
only_public=public only_public=public
@@ -6,9 +6,6 @@ from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.types.mutation_input import EditAircraftInput, CreateAircraftInput from graphql_schema.entities.types.mutation_input import EditAircraftInput, CreateAircraftInput
from graphql_schema.entities.types.types import Aircraft from graphql_schema.entities.types.types import Aircraft
from paths import AIRCRAFT_UPLOAD_DEST_PATH
from utils.file import delete_file
from utils.upload import handle_file_upload
class AircraftQueryResolver(BaseQueryResolver): class AircraftQueryResolver(BaseQueryResolver):
@@ -1,16 +1,15 @@
import os import os
import shutil import shutil
from typing import Type, Optional, List from typing import Optional
from PIL import Image from PIL import Image
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, delete, insert from sqlalchemy import delete, insert
from background_jobs.elevation import add_terrain_elevation_to_photo from background_jobs.elevation import add_terrain_elevation_to_photo
from background_jobs.photo import generate_thumbnail, resize_photo from background_jobs.photo import generate_thumbnail, resize_photo
from database import models from database import models
from database.transaction import get_session from database.transaction import get_session
from graphql_schema.entities.helpers.combobox import handle_combobox_save from graphql_schema.entities.helpers.combobox import handle_combobox_save
from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver, GQL_TYPE from graphql_schema.entities.resolvers.base import BaseMutationResolver, BaseQueryResolver
from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput from graphql_schema.entities.types.mutation_input import EditPhotoInput, UploadPhotoInput, AdjustmentInput
from graphql_schema.entities.types.types import Photo from graphql_schema.entities.types.types import Photo
from paths import get_photo_basepath from paths import get_photo_basepath
+3 -4
View File
@@ -10,7 +10,8 @@ from graphql_schema.dataloaders.multi_models import (
poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_track_dataloader, poi_photos_dataloader, flight_by_poi_dataloader, flight_copilots_dataloader, flight_track_dataloader,
photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader, photos_dataloader, flights_by_aircraft_dataloader, users_in_organization_dataloader,
aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_dataloader, aircrafts_from_organization_dataloader, user_organizations_dataloader, flights_by_event_dataloader,
flights_by_copilot_dataloader, public_flights_by_event_dataloader, public_flights_by_copilot_dataloader, photo_copilots_dataloader, photos_aircraft_dataloader, copilots_in_photo_dataloader flights_by_copilot_dataloader, public_flights_by_event_dataloader, public_flights_by_copilot_dataloader,
photo_copilots_dataloader, photos_aircraft_dataloader, copilots_in_photo_dataloader
) )
from graphql_schema.dataloaders.single_model import ( from graphql_schema.dataloaders.single_model import (
poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader, poi_dataloader, poi_type_dataloader, event_dataloader, aircraft_dataloader, airport_dataloader,
@@ -18,9 +19,7 @@ from graphql_schema.dataloaders.single_model import (
photo_dataloader, user_dataloader photo_dataloader, user_dataloader
) )
from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type from graphql_schema.sqlalchemy_to_strawberry_type import strawberry_sqlalchemy_type
from paths import ( from paths import get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH
get_public_url, get_avatar_url, get_title_image_url, get_photo_thumbnail_url, get_photo_url, FLIGHT_GPX_TRACK_PATH
)
@strawberry.type @strawberry.type
+3 -7
View File
@@ -1,7 +1,7 @@
import sentry_sdk import sentry_sdk
from datetime import timedelta from datetime import timedelta
from typing import Optional from typing import Optional
from fastapi import FastAPI, APIRouter, Depends, Security from fastapi import FastAPI, APIRouter, Depends, Security, HTTPException
from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie from fastapi_jwt import JwtAuthorizationCredentials, JwtAccessBearerCookie, JwtRefreshBearerCookie
from graphql import GraphQLError from graphql import GraphQLError
from sqlalchemy import select from sqlalchemy import select
@@ -10,10 +10,7 @@ from starlette.middleware.cors import CORSMiddleware
from starlette.responses import RedirectResponse, Response from starlette.responses import RedirectResponse, Response
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from strawberry.fastapi import GraphQLRouter from strawberry.fastapi import GraphQLRouter
from config import ( from config import APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS, SENTRY_DSN, REFRESH_TOKEN_VALIDITY_DAYS
APP_SECRET_KEY, GRAPHIQL, APP_DEBUG, ALLOW_CORS_ORIGINS, SENTRY_DSN, ACCESS_TOKEN_VALIDITY_MINUTES,
REFRESH_TOKEN_VALIDITY_DAYS
)
from database import models, async_session from database import models, async_session
from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint from endpoints.login import LoginEndpoint, LoginInput, RefreshEndpoint, LogoutEndpoint
from endpoints.photo_editor_preview import PhotoEditorEndpoint from endpoints.photo_editor_preview import PhotoEditorEndpoint
@@ -26,7 +23,6 @@ class App:
access_security = JwtAccessBearerCookie( access_security = JwtAccessBearerCookie(
secret_key=APP_SECRET_KEY, secret_key=APP_SECRET_KEY,
auto_error=False, auto_error=False,
access_expires_delta=timedelta(minutes=ACCESS_TOKEN_VALIDITY_MINUTES),
) )
refresh_security = JwtRefreshBearerCookie( refresh_security = JwtRefreshBearerCookie(
secret_key=APP_SECRET_KEY, secret_key=APP_SECRET_KEY,
@@ -39,7 +35,7 @@ class App:
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
enable_tracing=True, enable_tracing=True,
ignore_errors = [GraphQLError] ignore_errors=[GraphQLError, HTTPException]
) )
app = FastAPI() app = FastAPI()