Skip to content

Commit 36db2b7

Browse files
authored
Photo fix (#86)
* Better makefile * Photo response fix * No default page * Style * Fix tests * Tests style
1 parent a374c74 commit 36db2b7

16 files changed

Lines changed: 230 additions & 194 deletions

File tree

Makefile

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
run:
2-
source ./venv/bin/activate && uvicorn --reload --log-level debug calendar_backend.routes.base:app
2+
source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf calendar_backend.routes.base:app
33

44
configure: venv
55
source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt
@@ -8,12 +8,12 @@ venv:
88
python3.11 -m venv venv
99

1010
format:
11-
autoflake -r --in-place --remove-all-unused-imports ./calendar_backend
12-
isort ./calendar_backend
13-
black ./calendar_backend
11+
source ./venv/bin/activate && autoflake -r --in-place --remove-all-unused-imports ./calendar_backend
12+
source ./venv/bin/activate && isort ./calendar_backend
13+
source ./venv/bin/activate && black ./calendar_backend
1414

1515
db:
1616
docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-timetable_api postgres:15
1717

1818
migrate:
19-
alembic upgrade head
19+
source ./venv/bin/activate && alembic upgrade head

calendar_backend/methods/image.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import asyncio
2+
import os
3+
import random
4+
import string
5+
from concurrent.futures import ThreadPoolExecutor
6+
from functools import partial
7+
from io import BytesIO
8+
from typing import Final
9+
10+
import aiofiles
11+
from fastapi import File, HTTPException, UploadFile
12+
from PIL import Image
13+
from sqlalchemy.orm import Session
14+
15+
from calendar_backend.models.db import ApproveStatuses, Lecturer, Photo
16+
from calendar_backend.settings import get_settings
17+
18+
19+
SUPPORTED_FILE_EXTENSIONS: Final[list[str]] = ['png', 'svg', 'jpg', 'jpeg']
20+
settings = get_settings()
21+
22+
23+
async def upload_lecturer_photo(lecturer_id: int, session: Session, file: UploadFile = File(...)) -> Photo:
24+
lecturer = Lecturer.get(lecturer_id, session=session)
25+
random_string = ''.join(random.choice(string.ascii_letters) for _ in range(32))
26+
ext = file.filename.split('.')[-1]
27+
if ext not in SUPPORTED_FILE_EXTENSIONS:
28+
raise HTTPException(status_code=422, detail="Unsupported file extension")
29+
filename = f"{random_string}.{ext}"
30+
path = os.path.join(settings.STATIC_PATH, "photo", "lecturer", filename)
31+
async with aiofiles.open(path, 'wb') as out_file:
32+
content = await file.read()
33+
await async_image_process(content)
34+
await out_file.write(content)
35+
approve_status = ApproveStatuses.APPROVED if not settings.REQUIRE_REVIEW_PHOTOS else ApproveStatuses.PENDING
36+
photo = Photo(
37+
lecturer_id=lecturer_id,
38+
link=filename,
39+
approve_status=approve_status,
40+
)
41+
session.add(photo)
42+
session.flush()
43+
lecturer.avatar_id = lecturer.last_photo.id if lecturer.last_photo else lecturer.avatar_id
44+
session.flush()
45+
return photo
46+
47+
48+
def process_image(image_bytes: bytes) -> None:
49+
with Image.open(BytesIO(image_bytes)) as image:
50+
try:
51+
image.verify()
52+
except SyntaxError:
53+
raise HTTPException(status_code=422, detail="Corrupted file")
54+
55+
56+
thread_pool = ThreadPoolExecutor()
57+
58+
59+
async def async_image_process(image_bytes: bytes) -> None:
60+
loop = asyncio.get_event_loop()
61+
await loop.run_in_executor(thread_pool, partial(process_image, image_bytes))
62+
63+
64+
def get_photo_webpath(file_path: str):
65+
file_path = file_path.removeprefix('/')
66+
root_path = settings.ROOT_PATH.removesuffix('/')
67+
return f"{root_path}/static/photo/lecturer/{file_path}"

calendar_backend/methods/utils.py

Lines changed: 1 addition & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,6 @@
1-
import asyncio
21
import datetime
3-
import os
4-
import random
5-
import string
6-
from concurrent.futures import ThreadPoolExecutor
7-
from functools import partial
8-
from io import BytesIO
9-
from typing import Final
102

11-
import aiofiles
12-
from fastapi import File, HTTPException, UploadFile
13-
from PIL import Image
14-
from sqlalchemy.orm import Session
15-
16-
from calendar_backend.models.db import ApproveStatuses, Event, Group, Lecturer, Photo, Room
3+
from calendar_backend.models.db import Event, Group, Lecturer, Room
174
from calendar_backend.settings import get_settings
185

196

@@ -67,46 +54,3 @@ async def get_lecturer_lessons_in_daterange(
6754
if lesson.start_ts.date() >= date_start and lesson.end_ts.date() < date_end:
6855
events_list.append(lesson)
6956
return events_list
70-
71-
72-
SUPPORTED_FILE_EXTENSIONS: Final[list[str]] = ['png', 'svg', 'jpg', 'jpeg']
73-
74-
75-
async def upload_lecturer_photo(lecturer_id: int, session: Session, file: UploadFile = File(...)) -> Photo:
76-
lecturer = Lecturer.get(lecturer_id, session=session)
77-
random_string = ''.join(random.choice(string.ascii_letters) for _ in range(32))
78-
ext = file.filename.split('.')[-1]
79-
if ext not in SUPPORTED_FILE_EXTENSIONS:
80-
raise HTTPException(status_code=422, detail="Unsupported file extension")
81-
path = os.path.join(settings.STATIC_PATH, "photo", "lecturer", f"{random_string}.{ext}")
82-
async with aiofiles.open(path, 'wb') as out_file:
83-
content = await file.read()
84-
await async_image_process(content)
85-
await out_file.write(content)
86-
approve_status = ApproveStatuses.APPROVED if not settings.REQUIRE_REVIEW_PHOTOS else ApproveStatuses.PENDING
87-
photo = Photo(
88-
lecturer_id=lecturer_id,
89-
link=path,
90-
approve_status=approve_status,
91-
)
92-
session.add(photo)
93-
session.flush()
94-
lecturer.avatar_id = lecturer.last_photo.id if lecturer.last_photo else lecturer.avatar_id
95-
session.flush()
96-
return photo
97-
98-
99-
def process_image(image_bytes: bytes) -> None:
100-
with Image.open(BytesIO(image_bytes)) as image:
101-
try:
102-
image.verify()
103-
except SyntaxError:
104-
raise HTTPException(status_code=422, detail="Corrupted file")
105-
106-
107-
thread_pool = ThreadPoolExecutor()
108-
109-
110-
async def async_image_process(image_bytes: bytes) -> None:
111-
loop = asyncio.get_event_loop()
112-
await loop.run_in_executor(thread_pool, partial(process_image, image_bytes))

calendar_backend/routes/base.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,6 @@
3030
lecturer_comment_review_router as old_lecturer_comment_review_router,
3131
) # DEPRICATED TODO: Drop 2023-04-01
3232
from .lecturer import lecturer_comment_router as old_lecturer_comment_router # DEPRICATED TODO: Drop 2023-04-01
33-
from .lecturer import (
34-
lecturer_photo_review_router as old_lecturer_photo_review_router,
35-
) # DEPRICATED TODO: Drop 2023-04-01
3633
from .lecturer import lecturer_photo_router as old_lecturer_photo_router # DEPRICATED TODO: Drop 2023-04-01
3734
from .lecturer import lecturer_router as old_lecturer_router # DEPRICATED TODO: Drop 2023-04-01
3835
from .lecturer.comment import router as lecturer_comment_router
@@ -119,7 +116,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
119116
app.add_middleware(
120117
DBSessionMiddleware,
121118
db_url=settings.DB_DSN,
122-
engine_args={"pool_pre_ping": True},
119+
engine_args={"pool_pre_ping": True, "isolation_level": "AUTOCOMMIT"},
123120
)
124121
app.add_middleware(
125122
CORSMiddleware,
@@ -139,7 +136,6 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -
139136
app.include_router(old_lecturer_comment_router)
140137
app.include_router(old_lecturer_comment_review_router)
141138
app.include_router(old_lecturer_photo_router)
142-
app.include_router(old_lecturer_photo_review_router)
143139
app.include_router(old_group_router)
144140
app.include_router(old_room_router)
145141
app.include_router(old_event_router)

calendar_backend/routes/gcal.py

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from functools import lru_cache
66
from urllib.parse import unquote
77

8-
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
8+
from fastapi import APIRouter, BackgroundTasks, HTTPException
99
from fastapi.responses import RedirectResponse
1010
from fastapi.templating import Jinja2Templates
1111
from fastapi_sqlalchemy import db
@@ -39,17 +39,6 @@ def get_flow(state=""):
3939
)
4040

4141

42-
@gcal.get("/")
43-
async def home(request: Request):
44-
groups = [
45-
f"{row.number}, {row.name}" if row.name else f"{row.number}" for row in db.session.query(Group).filter().all()
46-
]
47-
return templates.TemplateResponse(
48-
"index.html",
49-
{"request": request, "groups": groups},
50-
)
51-
52-
5342
@gcal.get("/flow")
5443
async def get_user_flow(state: str):
5544
if settings.GOOGLE_CLIENT_SECRET:

calendar_backend/routes/lecturer/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@
22
from .comment_review import lecturer_comment_review_router
33
from .lecturer import lecturer_router
44
from .photo import lecturer_photo_router
5-
from .photo_review import lecturer_photo_review_router
65

76

87
__all__ = [
9-
"lecturer_photo_review_router",
108
"lecturer_comment_review_router",
119
"lecturer_comment_router",
1210
"lecturer_router",

calendar_backend/routes/lecturer/lecturer.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import logging
2-
from typing import Any
2+
from typing import Any, Literal
33

44
from auth_lib.fastapi import UnionAuth
55
from fastapi import APIRouter, Depends
66
from fastapi_sqlalchemy import db
7+
from sqlalchemy.orm import Query, joinedload
78

89
from calendar_backend.exceptions import ObjectNotFound
10+
from calendar_backend.methods.image import get_photo_webpath
911
from calendar_backend.models.db import ApproveStatuses, Lecturer
1012
from calendar_backend.models.db import Photo as DbPhoto
1113
from calendar_backend.routes.models import GetListLecturer, LecturerGet, LecturerPatch, LecturerPost
@@ -23,9 +25,10 @@
2325
@router.get("/{id}", response_model=LecturerGet)
2426
async def get_lecturer_by_id(id: int) -> LecturerGet:
2527
lecturer = Lecturer.get(id, session=db.session)
28+
result = LecturerGet.from_orm(Lecturer.get(id, session=db.session))
2629
if lecturer.avatar_id:
27-
lecturer.avatar_link = lecturer.avatar.link
28-
return LecturerGet.from_orm(Lecturer.get(id, session=db.session))
30+
result.avatar_link = get_photo_webpath(lecturer.avatar.link)
31+
return result
2932

3033

3134
@lecturer_router.get("/", response_model=GetListLecturer) # DEPRICATED TODO: Drop 2023-04-01
@@ -34,15 +37,26 @@ async def get_lecturers(
3437
query: str = "",
3538
limit: int = 10,
3639
offset: int = 0,
40+
order_by: Literal['first_name', 'last_name'] | None = None,
3741
) -> dict[str, Any]:
38-
res = Lecturer.get_all(session=db.session).filter(Lecturer.search(query))
42+
query: Query = Lecturer.get_all(session=db.session).filter(Lecturer.search(query))
43+
query = query.options(joinedload(Lecturer.avatar)) # Сразу загружаем аватарки
44+
if order_by:
45+
query = query.order_by(order_by)
46+
query = query.order_by('id')
3947
if limit:
40-
cnt, res = res.count(), res.offset(offset).limit(limit).all()
48+
cnt, query = query.count(), query.offset(offset).limit(limit)
4149
else:
42-
cnt, res = res.count(), res.offset(offset).all()
43-
for row in res:
44-
row.avatar_link = row.avatar.link if row.avatar else None
45-
result = [LecturerGet.from_orm(row) for row in res]
50+
cnt, query = query.count(), query.offset(offset)
51+
query = query.all()
52+
logger.debug(query)
53+
54+
result = []
55+
for row in query:
56+
row_get = LecturerGet.from_orm(row)
57+
if row.avatar:
58+
row_get.avatar_link = get_photo_webpath(row.avatar.link)
59+
result.append(row_get)
4660
return {
4761
"items": result,
4862
"limit": limit,
@@ -71,7 +85,7 @@ async def patch_lecturer(
7185
if photo.lecturer_id != id or photo.approve_status != ApproveStatuses.APPROVED:
7286
raise ObjectNotFound(DbPhoto, lecturer_inp.avatar_id)
7387
lecturer_upd = Lecturer.update(
74-
id, session=db.session, **lecturer_inp.dict(exclude_unset=True), avatar_link=photo.link
88+
id, session=db.session, **lecturer_inp.dict(exclude_unset=True), avatar_link=get_photo_webpath(photo.link)
7589
)
7690
else:
7791
lecturer_upd = Lecturer.update(id, session=db.session, **lecturer_inp.dict(exclude_unset=True))

calendar_backend/routes/lecturer/photo.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from fastapi_sqlalchemy import db
33

44
from calendar_backend.exceptions import ObjectNotFound
5-
from calendar_backend.methods import utils
5+
from calendar_backend.methods.image import get_photo_webpath, upload_lecturer_photo
66
from calendar_backend.models.db import ApproveStatuses, Lecturer
77
from calendar_backend.models.db import Photo as DbPhoto
88
from calendar_backend.routes.models import LecturerPhotos, Photo
9+
from calendar_backend.settings import get_settings
910

1011

12+
settings = get_settings()
1113
# DEPRICATED TODO: Drop 2023-04-01
1214
lecturer_photo_router = APIRouter(prefix="/timetable/lecturer/{lecturer_id}", tags=["Lecturer: Photo"], deprecated=True)
1315
router = APIRouter(prefix="/lecturer/{lecturer_id}", tags=["Lecturer: Photo"])
@@ -28,7 +30,7 @@ async def upload_photo(lecturer_id: int, photo: UploadFile = File(...)) -> Photo
2830
requests.post(url=f'{root}/timetable/lecturer/{lecturer_id}/photo', files={"photo": data})
2931
```
3032
"""
31-
photo = await utils.upload_lecturer_photo(lecturer_id, db.session, file=photo)
33+
photo = await upload_lecturer_photo(lecturer_id, db.session, file=photo)
3234
db.session.commit()
3335
return Photo.from_orm(photo)
3436

@@ -43,7 +45,12 @@ async def get_lecturer_photos(lecturer_id: int, limit: int = 10, offset: int = 0
4345
cnt, res = res.count(), res.offset(offset).limit(limit).all()
4446
else:
4547
cnt, res = res.count(), res.offset(offset).all()
46-
return LecturerPhotos(**{"items": [row.link for row in res], "limit": limit, "offset": offset, "total": cnt})
48+
return LecturerPhotos(
49+
items=[get_photo_webpath(row.link) for row in res],
50+
limit=limit,
51+
offset=offset,
52+
total=cnt,
53+
)
4754

4855

4956
@lecturer_photo_router.delete("/photo/{id}", response_model=None) # DEPRICATED TODO: Drop 2023-04-01

0 commit comments

Comments
 (0)