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 @@ - - - -
- - - - -