diff --git a/Makefile b/Makefile index db633b1b..2ef49c1a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ run: - source ./venv/bin/activate && uvicorn --reload --log-level debug calendar_backend.routes.base:app + source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf calendar_backend.routes.base:app configure: venv source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt @@ -8,12 +8,12 @@ venv: python3.11 -m venv venv format: - autoflake -r --in-place --remove-all-unused-imports ./calendar_backend - isort ./calendar_backend - black ./calendar_backend + source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./calendar_backend + source ./venv/bin/activate && isort ./calendar_backend + source ./venv/bin/activate && black ./calendar_backend db: docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-timetable_api postgres:15 migrate: - alembic upgrade head + source ./venv/bin/activate && alembic upgrade head diff --git a/calendar_backend/methods/image.py b/calendar_backend/methods/image.py new file mode 100644 index 00000000..a17d19d1 --- /dev/null +++ b/calendar_backend/methods/image.py @@ -0,0 +1,67 @@ +import asyncio +import os +import random +import string +from concurrent.futures import ThreadPoolExecutor +from functools import partial +from io import BytesIO +from typing import Final + +import aiofiles +from fastapi import File, HTTPException, UploadFile +from PIL import Image +from sqlalchemy.orm import Session + +from calendar_backend.models.db import ApproveStatuses, Lecturer, Photo +from calendar_backend.settings import get_settings + + +SUPPORTED_FILE_EXTENSIONS: Final[list[str]] = ['png', 'svg', 'jpg', 'jpeg'] +settings = get_settings() + + +async def upload_lecturer_photo(lecturer_id: int, session: Session, file: UploadFile = File(...)) -> Photo: + lecturer = Lecturer.get(lecturer_id, session=session) + random_string = ''.join(random.choice(string.ascii_letters) for _ in range(32)) + ext = file.filename.split('.')[-1] + if ext not in SUPPORTED_FILE_EXTENSIONS: + raise HTTPException(status_code=422, detail="Unsupported file extension") + filename = f"{random_string}.{ext}" + path = os.path.join(settings.STATIC_PATH, "photo", "lecturer", filename) + async with aiofiles.open(path, 'wb') as out_file: + content = await file.read() + await async_image_process(content) + await out_file.write(content) + approve_status = ApproveStatuses.APPROVED if not settings.REQUIRE_REVIEW_PHOTOS else ApproveStatuses.PENDING + photo = Photo( + lecturer_id=lecturer_id, + link=filename, + approve_status=approve_status, + ) + session.add(photo) + session.flush() + lecturer.avatar_id = lecturer.last_photo.id if lecturer.last_photo else lecturer.avatar_id + session.flush() + return photo + + +def process_image(image_bytes: bytes) -> None: + with Image.open(BytesIO(image_bytes)) as image: + try: + image.verify() + except SyntaxError: + raise HTTPException(status_code=422, detail="Corrupted file") + + +thread_pool = ThreadPoolExecutor() + + +async def async_image_process(image_bytes: bytes) -> None: + loop = asyncio.get_event_loop() + await loop.run_in_executor(thread_pool, partial(process_image, image_bytes)) + + +def get_photo_webpath(file_path: str): + file_path = file_path.removeprefix('/') + root_path = settings.ROOT_PATH.removesuffix('/') + return f"{root_path}/static/photo/lecturer/{file_path}" diff --git a/calendar_backend/methods/utils.py b/calendar_backend/methods/utils.py index 8e3222c9..88766af1 100644 --- a/calendar_backend/methods/utils.py +++ b/calendar_backend/methods/utils.py @@ -1,19 +1,6 @@ -import asyncio import datetime -import os -import random -import string -from concurrent.futures import ThreadPoolExecutor -from functools import partial -from io import BytesIO -from typing import Final -import aiofiles -from fastapi import File, HTTPException, UploadFile -from PIL import Image -from sqlalchemy.orm import Session - -from calendar_backend.models.db import ApproveStatuses, Event, Group, Lecturer, Photo, Room +from calendar_backend.models.db import Event, Group, Lecturer, Room from calendar_backend.settings import get_settings @@ -67,46 +54,3 @@ async def get_lecturer_lessons_in_daterange( if lesson.start_ts.date() >= date_start and lesson.end_ts.date() < date_end: events_list.append(lesson) return events_list - - -SUPPORTED_FILE_EXTENSIONS: Final[list[str]] = ['png', 'svg', 'jpg', 'jpeg'] - - -async def upload_lecturer_photo(lecturer_id: int, session: Session, file: UploadFile = File(...)) -> Photo: - lecturer = Lecturer.get(lecturer_id, session=session) - random_string = ''.join(random.choice(string.ascii_letters) for _ in range(32)) - ext = file.filename.split('.')[-1] - if ext not in SUPPORTED_FILE_EXTENSIONS: - raise HTTPException(status_code=422, detail="Unsupported file extension") - path = os.path.join(settings.STATIC_PATH, "photo", "lecturer", f"{random_string}.{ext}") - async with aiofiles.open(path, 'wb') as out_file: - content = await file.read() - await async_image_process(content) - await out_file.write(content) - approve_status = ApproveStatuses.APPROVED if not settings.REQUIRE_REVIEW_PHOTOS else ApproveStatuses.PENDING - photo = Photo( - lecturer_id=lecturer_id, - link=path, - approve_status=approve_status, - ) - session.add(photo) - session.flush() - lecturer.avatar_id = lecturer.last_photo.id if lecturer.last_photo else lecturer.avatar_id - session.flush() - return photo - - -def process_image(image_bytes: bytes) -> None: - with Image.open(BytesIO(image_bytes)) as image: - try: - image.verify() - except SyntaxError: - raise HTTPException(status_code=422, detail="Corrupted file") - - -thread_pool = ThreadPoolExecutor() - - -async def async_image_process(image_bytes: bytes) -> None: - loop = asyncio.get_event_loop() - await loop.run_in_executor(thread_pool, partial(process_image, image_bytes)) diff --git a/calendar_backend/routes/base.py b/calendar_backend/routes/base.py index ff3b06cd..e40c7ca3 100644 --- a/calendar_backend/routes/base.py +++ b/calendar_backend/routes/base.py @@ -30,9 +30,6 @@ lecturer_comment_review_router as old_lecturer_comment_review_router, ) # DEPRICATED TODO: Drop 2023-04-01 from .lecturer import lecturer_comment_router as old_lecturer_comment_router # DEPRICATED TODO: Drop 2023-04-01 -from .lecturer import ( - lecturer_photo_review_router as old_lecturer_photo_review_router, -) # DEPRICATED TODO: Drop 2023-04-01 from .lecturer import lecturer_photo_router as old_lecturer_photo_router # DEPRICATED TODO: Drop 2023-04-01 from .lecturer import lecturer_router as old_lecturer_router # DEPRICATED TODO: Drop 2023-04-01 from .lecturer.comment import router as lecturer_comment_router @@ -119,7 +116,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - app.add_middleware( DBSessionMiddleware, db_url=settings.DB_DSN, - engine_args={"pool_pre_ping": True}, + engine_args={"pool_pre_ping": True, "isolation_level": "AUTOCOMMIT"}, ) app.add_middleware( CORSMiddleware, @@ -139,7 +136,6 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) - app.include_router(old_lecturer_comment_router) app.include_router(old_lecturer_comment_review_router) app.include_router(old_lecturer_photo_router) -app.include_router(old_lecturer_photo_review_router) app.include_router(old_group_router) app.include_router(old_room_router) app.include_router(old_event_router) diff --git a/calendar_backend/routes/gcal.py b/calendar_backend/routes/gcal.py index 1a37fdb2..bf570e9b 100644 --- a/calendar_backend/routes/gcal.py +++ b/calendar_backend/routes/gcal.py @@ -5,7 +5,7 @@ from functools import lru_cache from urllib.parse import unquote -from fastapi import APIRouter, BackgroundTasks, HTTPException, Request +from fastapi import APIRouter, BackgroundTasks, HTTPException from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from fastapi_sqlalchemy import db @@ -39,17 +39,6 @@ def get_flow(state=""): ) -@gcal.get("/") -async def home(request: Request): - groups = [ - f"{row.number}, {row.name}" if row.name else f"{row.number}" for row in db.session.query(Group).filter().all() - ] - return templates.TemplateResponse( - "index.html", - {"request": request, "groups": groups}, - ) - - @gcal.get("/flow") async def get_user_flow(state: str): if settings.GOOGLE_CLIENT_SECRET: diff --git a/calendar_backend/routes/lecturer/__init__.py b/calendar_backend/routes/lecturer/__init__.py index 0b068543..9da8769a 100644 --- a/calendar_backend/routes/lecturer/__init__.py +++ b/calendar_backend/routes/lecturer/__init__.py @@ -2,11 +2,9 @@ from .comment_review import lecturer_comment_review_router from .lecturer import lecturer_router from .photo import lecturer_photo_router -from .photo_review import lecturer_photo_review_router __all__ = [ - "lecturer_photo_review_router", "lecturer_comment_review_router", "lecturer_comment_router", "lecturer_router", diff --git a/calendar_backend/routes/lecturer/lecturer.py b/calendar_backend/routes/lecturer/lecturer.py index 606fad05..02e783d6 100644 --- a/calendar_backend/routes/lecturer/lecturer.py +++ b/calendar_backend/routes/lecturer/lecturer.py @@ -1,11 +1,13 @@ import logging -from typing import Any +from typing import Any, Literal from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends from fastapi_sqlalchemy import db +from sqlalchemy.orm import Query, joinedload from calendar_backend.exceptions import ObjectNotFound +from calendar_backend.methods.image import get_photo_webpath from calendar_backend.models.db import ApproveStatuses, Lecturer from calendar_backend.models.db import Photo as DbPhoto from calendar_backend.routes.models import GetListLecturer, LecturerGet, LecturerPatch, LecturerPost @@ -23,9 +25,10 @@ @router.get("/{id}", response_model=LecturerGet) async def get_lecturer_by_id(id: int) -> LecturerGet: lecturer = Lecturer.get(id, session=db.session) + result = LecturerGet.from_orm(Lecturer.get(id, session=db.session)) if lecturer.avatar_id: - lecturer.avatar_link = lecturer.avatar.link - return LecturerGet.from_orm(Lecturer.get(id, session=db.session)) + result.avatar_link = get_photo_webpath(lecturer.avatar.link) + return result @lecturer_router.get("/", response_model=GetListLecturer) # DEPRICATED TODO: Drop 2023-04-01 @@ -34,15 +37,26 @@ async def get_lecturers( query: str = "", limit: int = 10, offset: int = 0, + order_by: Literal['first_name', 'last_name'] | None = None, ) -> dict[str, Any]: - res = Lecturer.get_all(session=db.session).filter(Lecturer.search(query)) + query: Query = Lecturer.get_all(session=db.session).filter(Lecturer.search(query)) + query = query.options(joinedload(Lecturer.avatar)) # Сразу загружаем аватарки + if order_by: + query = query.order_by(order_by) + query = query.order_by('id') if limit: - cnt, res = res.count(), res.offset(offset).limit(limit).all() + cnt, query = query.count(), query.offset(offset).limit(limit) else: - cnt, res = res.count(), res.offset(offset).all() - for row in res: - row.avatar_link = row.avatar.link if row.avatar else None - result = [LecturerGet.from_orm(row) for row in res] + cnt, query = query.count(), query.offset(offset) + query = query.all() + logger.debug(query) + + result = [] + for row in query: + row_get = LecturerGet.from_orm(row) + if row.avatar: + row_get.avatar_link = get_photo_webpath(row.avatar.link) + result.append(row_get) return { "items": result, "limit": limit, @@ -71,7 +85,7 @@ async def patch_lecturer( if photo.lecturer_id != id or photo.approve_status != ApproveStatuses.APPROVED: raise ObjectNotFound(DbPhoto, lecturer_inp.avatar_id) lecturer_upd = Lecturer.update( - id, session=db.session, **lecturer_inp.dict(exclude_unset=True), avatar_link=photo.link + id, session=db.session, **lecturer_inp.dict(exclude_unset=True), avatar_link=get_photo_webpath(photo.link) ) else: lecturer_upd = Lecturer.update(id, session=db.session, **lecturer_inp.dict(exclude_unset=True)) diff --git a/calendar_backend/routes/lecturer/photo.py b/calendar_backend/routes/lecturer/photo.py index 08b62c21..224e5078 100644 --- a/calendar_backend/routes/lecturer/photo.py +++ b/calendar_backend/routes/lecturer/photo.py @@ -2,12 +2,14 @@ from fastapi_sqlalchemy import db from calendar_backend.exceptions import ObjectNotFound -from calendar_backend.methods import utils +from calendar_backend.methods.image import get_photo_webpath, upload_lecturer_photo from calendar_backend.models.db import ApproveStatuses, Lecturer from calendar_backend.models.db import Photo as DbPhoto from calendar_backend.routes.models import LecturerPhotos, Photo +from calendar_backend.settings import get_settings +settings = get_settings() # DEPRICATED TODO: Drop 2023-04-01 lecturer_photo_router = APIRouter(prefix="/timetable/lecturer/{lecturer_id}", tags=["Lecturer: Photo"], deprecated=True) router = APIRouter(prefix="/lecturer/{lecturer_id}", tags=["Lecturer: Photo"]) @@ -28,7 +30,7 @@ async def upload_photo(lecturer_id: int, photo: UploadFile = File(...)) -> Photo requests.post(url=f'{root}/timetable/lecturer/{lecturer_id}/photo', files={"photo": data}) ``` """ - photo = await utils.upload_lecturer_photo(lecturer_id, db.session, file=photo) + photo = await upload_lecturer_photo(lecturer_id, db.session, file=photo) db.session.commit() return Photo.from_orm(photo) @@ -43,7 +45,12 @@ async def get_lecturer_photos(lecturer_id: int, limit: int = 10, offset: int = 0 cnt, res = res.count(), res.offset(offset).limit(limit).all() else: cnt, res = res.count(), res.offset(offset).all() - return LecturerPhotos(**{"items": [row.link for row in res], "limit": limit, "offset": offset, "total": cnt}) + return LecturerPhotos( + items=[get_photo_webpath(row.link) for row in res], + limit=limit, + offset=offset, + total=cnt, + ) @lecturer_photo_router.delete("/photo/{id}", response_model=None) # DEPRICATED TODO: Drop 2023-04-01 diff --git a/calendar_backend/routes/lecturer/photo_review.py b/calendar_backend/routes/lecturer/photo_review.py index 3ad078db..6c96c8ab 100644 --- a/calendar_backend/routes/lecturer/photo_review.py +++ b/calendar_backend/routes/lecturer/photo_review.py @@ -1,50 +1,81 @@ +from typing import Literal + from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends from fastapi_sqlalchemy import db -from pydantic import parse_obj_as +from sqlalchemy.orm import Query -from calendar_backend.exceptions import ObjectNotFound -from calendar_backend.models.db import ApproveStatuses, Lecturer +from calendar_backend.methods.image import get_photo_webpath +from calendar_backend.models.db import ApproveStatuses from calendar_backend.models.db import Photo as DbPhoto from calendar_backend.routes.models import Action, Photo +from calendar_backend.routes.models.base import Base as BaseSchema + + +router = APIRouter(prefix="/lecturer/photo/review", tags=["Lecturer: Photo Review"]) + +class Photo(BaseSchema): + id: int + lecturer_id: int + link: str -# DEPRICATED TODO: Drop 2023-04-01 -lecturer_photo_review_router = APIRouter( - prefix="/timetable/lecturer/{lecturer_id}/photo", tags=["Lecturer: Photo Review"], deprecated=True -) -router = APIRouter(prefix="/lecturer/{lecturer_id}/photo", tags=["Lecturer: Photo Review"]) +class PhotoListResponse(BaseSchema): + items: list[Photo] + limit: int + offset: int + total: int -@lecturer_photo_review_router.get("/review/", response_model=list[Photo]) # DEPRICATED TODO: Drop 2023-04-01 -@router.get("/review/", response_model=list[Photo]) + +@router.get("", response_model=PhotoListResponse) async def get_unreviewed_photos( - lecturer_id: int, _=Depends(UnionAuth(scopes=["timetable.lecturer.photo.review"])) -) -> list[Photo]: - photos = ( - DbPhoto.get_all(session=db.session, only_approved=False) - .filter(DbPhoto.lecturer_id == lecturer_id, DbPhoto.approve_status == ApproveStatuses.PENDING) - .all() + limit: int = 10, + offset: int = 0, + order_by: Literal['lecturer_id'] | None = None, + lecturer_id: int = None, + _=Depends(UnionAuth(scopes=["timetable.lecturer.photo.review"])), +): + query: Query = DbPhoto.get_all(session=db.session, only_approved=False) + query = query.filter(DbPhoto.approve_status == ApproveStatuses.PENDING) + if lecturer_id: + query = query.filter(DbPhoto.lecturer_id == lecturer_id) + if order_by: + query = query.order_by(order_by) + query = query.order_by('id') + if limit: + cnt, query = query.count(), query.offset(offset).limit(limit) + else: + cnt, query = query.count(), query.offset(offset) + query = query.all() + + result = [] + for row in query: + get_row = Photo.from_orm(row) + get_row.link = get_photo_webpath(row.link) + result.append(get_row) + + return PhotoListResponse( + items=result, + limit=limit, + offset=offset, + total=cnt, ) - return parse_obj_as(list[Photo], photos) -@lecturer_photo_review_router.post("/{id}/review/", response_model=Photo) # DEPRICATED TODO: Drop 2023-04-01 -@router.post("/{id}/review/", response_model=Photo) +@router.post("/{id}", response_model=Photo | None) async def review_photo( id: int, - lecturer_id: int, action: Action, _=Depends(UnionAuth(scopes=["timetable.lecturer.photo.review"])), ) -> Photo: - lecturer = Lecturer.get(lecturer_id, session=db.session) photo = DbPhoto.get(id, only_approved=False, session=db.session) - if photo.lecturer_id != lecturer_id or photo.approve_status is not ApproveStatuses.PENDING: - raise ObjectNotFound(DbPhoto, id) DbPhoto.update(photo.id, approve_status=action.action, session=db.session) if action == ApproveStatuses.DECLINED: DbPhoto.delete(photo.id, session=db.session) + db.session.flush() + return None + if not photo.lecturer.avatar: + photo.lecturer.avatar_id = photo.id db.session.flush() - lecturer.avatar_id = lecturer.last_photo.id if lecturer.last_photo else lecturer.avatar_id - db.session.commit() return Photo.from_orm(photo) diff --git a/calendar_backend/settings.py b/calendar_backend/settings.py index 110380d7..c625ee63 100644 --- a/calendar_backend/settings.py +++ b/calendar_backend/settings.py @@ -16,7 +16,7 @@ class Settings(UnionAuthSettings, BaseSettings): "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/userinfo.email", ] - STATIC_PATH: DirectoryPath | None + STATIC_PATH: DirectoryPath | None = './static' ADMIN_SECRET: dict[str, str] = {"admin": "42"} REQUIRE_REVIEW_PHOTOS: bool = True REQUIRE_REVIEW_LECTURER_COMMENT: bool = True diff --git a/calendar_backend/templates/index.html b/calendar_backend/templates/index.html deleted file mode 100644 index 6e76f3a7..00000000 --- a/calendar_backend/templates/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - Auth calendar - - - - - - - -
-

Подписка на календарь группы

-
- - -
- - -
- - - - \ No newline at end of file diff --git a/logging_dev.conf b/logging_dev.conf new file mode 100644 index 00000000..78372724 --- /dev/null +++ b/logging_dev.conf @@ -0,0 +1,21 @@ +[loggers] +keys=root + +[handlers] +keys=all + +[formatters] +keys=main + +[logger_root] +level=DEBUG +handlers=all + +[handler_all] +class=StreamHandler +formatter=main +level=DEBUG +args=(sys.stdout,) + +[formatter_main] +format=%(asctime)s %(levelname)-8s %(name)-15s %(message)s diff --git a/migrations/versions/63263ee9e08e_fix_photo_paths.py b/migrations/versions/63263ee9e08e_fix_photo_paths.py new file mode 100644 index 00000000..4afb474b --- /dev/null +++ b/migrations/versions/63263ee9e08e_fix_photo_paths.py @@ -0,0 +1,24 @@ +"""Fix photo paths + +Revision ID: 63263ee9e08e +Revises: 6d57978a236e +Create Date: 2023-03-20 15:15:20.345969 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '63263ee9e08e' +down_revision = '6d57978a236e' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("UPDATE photo SET link=REGEXP_REPLACE(link, '.*/([^/]+)$', '\\1', 'i')") + + +def downgrade(): + pass diff --git a/tests/conftest.py b/tests/conftest.py index 12a663a3..ef238a4c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,3 @@ -import os.path from datetime import datetime import pytest @@ -9,7 +8,7 @@ from starlette import status from calendar_backend.models.base import DeclarativeBase -from calendar_backend.models.db import CommentLecturer, Event, Group, Lecturer, Photo, Room +from calendar_backend.models.db import Event, Group, Lecturer, Room from calendar_backend.routes import app from calendar_backend.settings import get_settings @@ -38,7 +37,7 @@ def client_auth(mocker: MockerFixture): @pytest.fixture() def dbsession(): settings = get_settings() - engine = create_engine(settings.DB_DSN) + engine = create_engine(settings.DB_DSN, isolation_level='AUTOCOMMIT') TestingSessionLocal = sessionmaker(bind=engine) DeclarativeBase.metadata.create_all(bind=engine) return TestingSessionLocal() @@ -94,20 +93,6 @@ def lecturer_path(client_auth: TestClient, dbsession: Session): dbsession.commit() -@pytest.fixture() -def photo_path(client_auth: TestClient, dbsession: Session, lecturer_path: str): - RESOURCE = f"{lecturer_path}/photo" - with open(os.path.dirname(__file__) + "/photo.png", "rb") as f: - response = client_auth.post(RESOURCE, files={"photo": f}) - assert response.status_code == status.HTTP_200_OK, response.json() - id_ = response.json()["id"] - client_auth.post(f"{RESOURCE}/{id_}/review/", json={"action": "Approved"}) - yield RESOURCE + "/" + str(id_) - response_model: CommentLecturer = dbsession.query(Photo).get(id_) - dbsession.delete(response_model) - dbsession.commit() - - @pytest.fixture() def event_path(client_auth: TestClient, dbsession: Session, lecturer_path, room_path, group_path): RESOURCE = f"/event/" diff --git a/tests/photo.png b/tests/lecturer/photo.png similarity index 100% rename from tests/photo.png rename to tests/lecturer/photo.png diff --git a/tests/lecturer/photos.py b/tests/lecturer/photos.py index 71720a8c..e194de99 100644 --- a/tests/lecturer/photos.py +++ b/tests/lecturer/photos.py @@ -1,8 +1,11 @@ import os +import pytest from fastapi.testclient import TestClient +from sqlalchemy.orm import Session from starlette import status +from calendar_backend.models.db import Photo from calendar_backend.settings import get_settings @@ -10,6 +13,20 @@ settings.STATIC_PATH = './static' +@pytest.fixture() +def photo_path(client_auth: TestClient, dbsession: Session, lecturer_path: str): + RESOURCE = f"{lecturer_path}/photo" + with open(os.path.dirname(__file__) + "/photo.png", "rb") as f: + response = client_auth.post(RESOURCE, files={"photo": f}) + assert response.status_code == status.HTTP_200_OK, response.json() + id_ = response.json()["id"] + client_auth.post(f"/lecturer/photo/review/{id_}", json={"action": "Approved"}) + yield RESOURCE + "/" + str(id_) + response_model = dbsession.query(Photo).get(id_) + dbsession.delete(response_model) + dbsession.commit() + + def test_read_all(client_auth: TestClient, photo_path: str): photo_lib_path = '/'.join(photo_path.split("/")[:-1]) response = client_auth.get(photo_lib_path, params={"limit": 10})