From 3002415b64d73fe139ede5165451293e1e830f7e Mon Sep 17 00:00:00 2001 From: Umiacha Date: Sat, 12 Jul 2025 02:25:55 +0300 Subject: [PATCH 01/12] Add query param mark. --- rating_api/routes/lecturer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index 64632fa..faa5d9a 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -83,6 +83,7 @@ async def get_lecturers( enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], default="mark_weighted", ), + mark: float = Query(default=None, ge=-2, le=2), subject: str = Query(''), name: str = Query(''), asc_order: bool = False, @@ -143,6 +144,10 @@ async def get_lecturers( for comment in db_lecturer.comments if comment.review_status is ReviewStatus.APPROVED ] + if (mark is not None + and approved_comments + and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark): + continue if "comments" in info and approved_comments: lecturer_to_result.comments = sorted( approved_comments, key=lambda comment: comment.create_ts, reverse=True From 05cfbb0f5865e66f7cf27f9d99684944aad65f8b Mon Sep 17 00:00:00 2001 From: Umiacha Date: Tue, 19 Aug 2025 17:08:39 +0300 Subject: [PATCH 02/12] Add mark in query and Filter in handler. --- rating_api/routes/lecturer.py | 152 ++++++++++++++++++++++++++++------ rating_api/schemas/models.py | 73 +++++++++++++++- requirements.txt | 1 + 3 files changed, 197 insertions(+), 29 deletions(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index faa5d9a..b584898 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -4,11 +4,12 @@ from fastapi import APIRouter, Depends, Query from fastapi_sqlalchemy import db from sqlalchemy import and_ +from fastapi_filter import FilterDepends from rating_api.exceptions import AlreadyExists, ObjectNotFound from rating_api.models import Comment, Lecturer, LecturerUserComment, ReviewStatus from rating_api.schemas.base import StatusResponseModel -from rating_api.schemas.models import CommentGet, LecturerGet, LecturerGetAll, LecturerPatch, LecturerPost +from rating_api.schemas.models import CommentGet, LecturerGet, LecturerGetAll, LecturerPatch, LecturerPost, LecturersFilter from rating_api.utils.mark import calc_weighted_mark @@ -74,19 +75,121 @@ async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query( return result +# @lecturer.get("", response_model=LecturerGetAll) +# async def get_lecturers( +# limit: int = 10, +# offset: int = 0, +# info: list[Literal["comments", "mark"]] = Query(default=[]), +# order_by: str = Query( +# enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], +# default="mark_weighted", +# ), +# mark: float = Query(default=None, ge=-2, le=2), +# subject: str = Query(''), +# name: str = Query(''), +# asc_order: bool = False, +# ) -> LecturerGetAll: +# """ +# `limit` - максимальное количество возвращаемых преподавателей + +# `offset` - нижняя граница получения преподавателей, т.е. если по дефолту первым возвращается преподаватель с условным номером N, то при наличии ненулевого offset будет возвращаться преподаватель с номером N + offset + +# `order_by` - возможные значения `"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"`. +# Если передано `'last_name'` - возвращается список преподавателей отсортированных по алфавиту по фамилиям +# Если передано `'mark_...'` - возвращается список преподавателей отсортированных по конкретной оценке + +# `info` - возможные значения `'comments'`, `'mark'`. +# Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. +# Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. + +# `subject` +# Если передано `subject` - возвращает всех преподавателей, для которых переданное значение совпадает с одним из их предметов преподавания. +# Также возвращает всех преподавателей, у которых есть комментарий с совпадающим с данным subject. + +# `name` +# Поле для ФИО. Если передано `name` - возвращает всех преподователей, для которых нашлись совпадения с переданной строкой + +# `asc_order` +# Если передано true, сортировать в порядке возрастания +# Иначе - в порядке убывания +# """ +# lecturers_query = ( +# Lecturer.query(session=db.session) +# .outerjoin(Lecturer.comments) # TODO: переписать с LEFT JOIN только комментов, которые Comment.review_status == ReviewStatus.APPROVED. +# .group_by(Lecturer.id) +# .filter(Lecturer.search_by_subject(subject)) +# .filter(Lecturer.search_by_name(name)) + # .order_by( +# *( +# Lecturer.order_by_mark(order_by, asc_order) +# if "mark" in order_by +# else Lecturer.order_by_name(order_by, asc_order) +# ) +# ) +# ) + +# lecturers = lecturers_query.offset(offset).limit(limit).all() +# lecturers_count = lecturers_query.group_by(Lecturer.id).count() + +# if not lecturers: +# raise ObjectNotFound(Lecturer, 'all') +# result = LecturerGetAll(limit=limit, offset=offset, total=lecturers_count) +# if "mark" in info: +# mean_mark_general = Lecturer.mean_mark_general() +# for db_lecturer in lecturers: +# lecturer_to_result: LecturerGet = LecturerGet.model_validate(db_lecturer) +# lecturer_to_result.comments = None +# if db_lecturer.comments: +# approved_comments: list[CommentGet] = [ +# CommentGet.model_validate(comment) +# for comment in db_lecturer.comments +# if comment.review_status is ReviewStatus.APPROVED +# ] +# if (mark is not None +# and approved_comments +# and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark): +# continue +# if "comments" in info and approved_comments: +# lecturer_to_result.comments = sorted( +# approved_comments, key=lambda comment: comment.create_ts, reverse=True +# ) +# if "mark" in info and approved_comments: +# lecturer_to_result.mark_freebie = sum([comment.mark_freebie for comment in approved_comments]) / len( +# approved_comments +# ) +# lecturer_to_result.mark_kindness = sum(comment.mark_kindness for comment in approved_comments) / len( +# approved_comments +# ) +# lecturer_to_result.mark_clarity = sum(comment.mark_clarity for comment in approved_comments) / len( +# approved_comments +# ) +# lecturer_to_result.mark_general = sum(comment.mark_general for comment in approved_comments) / len( +# approved_comments +# ) +# lecturer_to_result.mark_weighted = calc_weighted_mark( +# lecturer_to_result.mark_general, len(approved_comments), mean_mark_general +# ) +# if approved_comments: +# lecturer_to_result.subjects = list({comment.subject for comment in approved_comments}) +# result.lecturers.append(lecturer_to_result) +# return result + + +# //////////////// Мое переписывание ручки @lecturer.get("", response_model=LecturerGetAll) async def get_lecturers( + lecturer_filter = FilterDepends(LecturersFilter), limit: int = 10, offset: int = 0, info: list[Literal["comments", "mark"]] = Query(default=[]), - order_by: str = Query( - enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], - default="mark_weighted", - ), + # order_by: str = Query( + # enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], + # default="mark_weighted", + # ), mark: float = Query(default=None, ge=-2, le=2), - subject: str = Query(''), - name: str = Query(''), - asc_order: bool = False, + # subject: str = Query(''),lecturers_query + # name: str = Query(''), + # asc_order: bool = False, ) -> LecturerGetAll: """ `limit` - максимальное количество возвращаемых преподавателей @@ -96,6 +199,8 @@ async def get_lecturers( `order_by` - возможные значения `"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"`. Если передано `'last_name'` - возвращается список преподавателей отсортированных по алфавиту по фамилиям Если передано `'mark_...'` - возвращается список преподавателей отсортированных по конкретной оценке + Если передано просто так (или с '+' в начале параметра), то сортирует по возрастанию + С '-' в начале -- по убыванию. `info` - возможные значения `'comments'`, `'mark'`. Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. @@ -108,30 +213,20 @@ async def get_lecturers( `name` Поле для ФИО. Если передано `name` - возвращает всех преподователей, для которых нашлись совпадения с переданной строкой - `asc_order` - Если передано true, сортировать в порядке возрастания - Иначе - в порядке убывания + `mark` + Поле для оценки. Если передано, то возвращает только тех преподавателей, для которых средняя общая оценка ('general_mark') + больше, чем переданный 'mark'. """ - lecturers_query = ( - Lecturer.query(session=db.session) - .outerjoin(Lecturer.comments) - .group_by(Lecturer.id) - .filter(Lecturer.search_by_subject(subject)) - .filter(Lecturer.search_by_name(name)) - .order_by( - *( - Lecturer.order_by_mark(order_by, asc_order) - if "mark" in order_by - else Lecturer.order_by_name(order_by, asc_order) - ) - ) + lecturers_query = lecturer_filter.filter( + Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) ) - + lecturers_query = lecturer_filter.sort(lecturers_query) + lecturers_query = lecturer_filter.sort(lecturers_query) lecturers = lecturers_query.offset(offset).limit(limit).all() lecturers_count = lecturers_query.group_by(Lecturer.id).count() - if not lecturers: - raise ObjectNotFound(Lecturer, 'all') + # if not lecturers: + # raise ObjectNotFound(Lecturer, 'all') result = LecturerGetAll(limit=limit, offset=offset, total=lecturers_count) if "mark" in info: mean_mark_general = Lecturer.mean_mark_general() @@ -171,7 +266,10 @@ async def get_lecturers( if approved_comments: lecturer_to_result.subjects = list({comment.subject for comment in approved_comments}) result.lecturers.append(lecturer_to_result) + if len(result.lecturers) == 0: + raise ObjectNotFound(Lecturer, 'all') return result +# //////////////// @lecturer.patch("/{id}", response_model=LecturerGet) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index af01659..8c7bc55 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -1,10 +1,14 @@ import datetime from uuid import UUID +from typing import List, Literal, Optional -from pydantic import field_validator +from fastapi import Query +from pydantic import Field, field_validator, ValidationError, ValidationInfo +from fastapi_filter.contrib.sqlalchemy import Filter +from sqlalchemy import func, or_ from rating_api.exceptions import WrongMark -from rating_api.models import ReviewStatus +from rating_api.models import ReviewStatus, Lecturer, Comment from rating_api.schemas.base import Base @@ -171,3 +175,68 @@ class LecturerPatch(Base): middle_name: str | None = None avatar_link: str | None = None timetable_id: int | None = None + + +class LecturersFilter(Filter): + subject: str = '' + name: str = '' + order_by: List[str] = ['mark_weighted',] + + @field_validator("*", mode="before", check_fields=False) + def validate_order_by(cls, value, field: ValidationInfo): + return value + + @field_validator('order_by', mode='before') + @classmethod + def check_order_param(cls, value: str) -> str: + """Проверяет, что значение поля (без +/-) входит в список возможных.""" + allowed_ordering = {"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"} + cleaned_value = value.replace("+", "").replace("-", "") + if cleaned_value in allowed_ordering: + return value + else: + raise ValueError(f'"order_by"-field must contain value from {allowed_ordering}.') + + def filter(self, query: Query) -> Query: + # query = super().filter(query) # FIXME: стоит ли оставлять классическое поведение? У нас ведь не предусмотрены такие фильтрации... + if self.subject: + query = query.filter(self.Constants.model.search_by_subject(self.subject)) + if self.name: + query = query.filter(self.Constants.model.search_by_name(self.name)) + return query + + # if self.subject: + # subject = self.subject.lower() + # query.filter(func.lower(Comment.subject).contains(subject)) + # if self.name: + # full_name = self.name.split(' ') + # for name in full_name: + # name = name.lower() + # query.filter( + # or_( + # func.lower(self.Constants.model.first_name).contains(name), + # func.lower(self.Constants.model.middle_name).contains(name), + # func.lower(self.Constants.model.last_name).contains(name) + # ) + # ) + + def sort(self, query: Query) -> Query: # FIXME: почему-то при добавлении знака к order_by-параметру, все ломается! + # print(f'{self.ordering_values=}') + if not self.ordering_values: + return query + elif len(self.ordering_values) > 1: + raise ValueError('order_by (хотя бы пока что) поддерживает лишь один параметр для сортировки!') + + for field_name in self.ordering_values: + direction = True + if field_name.startswith("-"): + direction = False + field_name = field_name.replace("-", "").replace("+", "") + if field_name.startswith('mark_'): + query = query.order_by(*self.Constants.model.order_by_mark(field_name, direction)) + else: + query = query.order_by(*self.Constants.model.order_by_name(field_name, direction)) + return query + + class Constants(Filter.Constants): + model = Lecturer diff --git a/requirements.txt b/requirements.txt index 920ab75..17be111 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ auth-lib-profcomff[fastapi] aiohttp fastapi fastapi-sqlalchemy +fastapi-filter[sqlalchemy] gunicorn logging-profcomff psycopg2-binary From 770f13be813ef349f4ce946c6042743b1c0d2064 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Tue, 19 Aug 2025 17:16:17 +0300 Subject: [PATCH 03/12] Add await to call. --- rating_api/routes/comment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index 529398c..c531d69 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -127,7 +127,7 @@ async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depen else: give_achievement = False if give_achievement: - session.post( + await session.post( settings.API_URL + f"achievement/achievement/{settings.FIRST_COMMENT_ACHIEVEMENT_ID}/reciever/{user.get('id'):}", headers={"Accept": "application/json", "Authorization": settings.ACHIEVEMENT_GIVE_TOKEN}, From a89584a225c23f218797b97d3dfa796a4fb2308c Mon Sep 17 00:00:00 2001 From: Umiacha Date: Tue, 19 Aug 2025 17:18:58 +0300 Subject: [PATCH 04/12] TODO for dev. --- rating_api/routes/lecturer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index b584898..a39b760 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -218,7 +218,7 @@ async def get_lecturers( больше, чем переданный 'mark'. """ lecturers_query = lecturer_filter.filter( - Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) + Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) # TODO: потестить без части с outerjoin. ) lecturers_query = lecturer_filter.sort(lecturers_query) lecturers_query = lecturer_filter.sort(lecturers_query) From 2e11ef925779d4fe3bc0e84e7f12a7fee75012e7 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Wed, 20 Aug 2025 00:38:48 +0300 Subject: [PATCH 05/12] Add tests and clean code. --- rating_api/routes/lecturer.py | 113 +---------------- rating_api/schemas/models.py | 20 +-- tests/conftest.py | 3 +- tests/test_routes/test_lecturer.py | 197 ++++++++++++++++++++++++++++- 4 files changed, 200 insertions(+), 133 deletions(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index a39b760..2344cba 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -75,121 +75,13 @@ async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query( return result -# @lecturer.get("", response_model=LecturerGetAll) -# async def get_lecturers( -# limit: int = 10, -# offset: int = 0, -# info: list[Literal["comments", "mark"]] = Query(default=[]), -# order_by: str = Query( -# enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], -# default="mark_weighted", -# ), -# mark: float = Query(default=None, ge=-2, le=2), -# subject: str = Query(''), -# name: str = Query(''), -# asc_order: bool = False, -# ) -> LecturerGetAll: -# """ -# `limit` - максимальное количество возвращаемых преподавателей - -# `offset` - нижняя граница получения преподавателей, т.е. если по дефолту первым возвращается преподаватель с условным номером N, то при наличии ненулевого offset будет возвращаться преподаватель с номером N + offset - -# `order_by` - возможные значения `"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"`. -# Если передано `'last_name'` - возвращается список преподавателей отсортированных по алфавиту по фамилиям -# Если передано `'mark_...'` - возвращается список преподавателей отсортированных по конкретной оценке - -# `info` - возможные значения `'comments'`, `'mark'`. -# Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. -# Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. - -# `subject` -# Если передано `subject` - возвращает всех преподавателей, для которых переданное значение совпадает с одним из их предметов преподавания. -# Также возвращает всех преподавателей, у которых есть комментарий с совпадающим с данным subject. - -# `name` -# Поле для ФИО. Если передано `name` - возвращает всех преподователей, для которых нашлись совпадения с переданной строкой - -# `asc_order` -# Если передано true, сортировать в порядке возрастания -# Иначе - в порядке убывания -# """ -# lecturers_query = ( -# Lecturer.query(session=db.session) -# .outerjoin(Lecturer.comments) # TODO: переписать с LEFT JOIN только комментов, которые Comment.review_status == ReviewStatus.APPROVED. -# .group_by(Lecturer.id) -# .filter(Lecturer.search_by_subject(subject)) -# .filter(Lecturer.search_by_name(name)) - # .order_by( -# *( -# Lecturer.order_by_mark(order_by, asc_order) -# if "mark" in order_by -# else Lecturer.order_by_name(order_by, asc_order) -# ) -# ) -# ) - -# lecturers = lecturers_query.offset(offset).limit(limit).all() -# lecturers_count = lecturers_query.group_by(Lecturer.id).count() - -# if not lecturers: -# raise ObjectNotFound(Lecturer, 'all') -# result = LecturerGetAll(limit=limit, offset=offset, total=lecturers_count) -# if "mark" in info: -# mean_mark_general = Lecturer.mean_mark_general() -# for db_lecturer in lecturers: -# lecturer_to_result: LecturerGet = LecturerGet.model_validate(db_lecturer) -# lecturer_to_result.comments = None -# if db_lecturer.comments: -# approved_comments: list[CommentGet] = [ -# CommentGet.model_validate(comment) -# for comment in db_lecturer.comments -# if comment.review_status is ReviewStatus.APPROVED -# ] -# if (mark is not None -# and approved_comments -# and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark): -# continue -# if "comments" in info and approved_comments: -# lecturer_to_result.comments = sorted( -# approved_comments, key=lambda comment: comment.create_ts, reverse=True -# ) -# if "mark" in info and approved_comments: -# lecturer_to_result.mark_freebie = sum([comment.mark_freebie for comment in approved_comments]) / len( -# approved_comments -# ) -# lecturer_to_result.mark_kindness = sum(comment.mark_kindness for comment in approved_comments) / len( -# approved_comments -# ) -# lecturer_to_result.mark_clarity = sum(comment.mark_clarity for comment in approved_comments) / len( -# approved_comments -# ) -# lecturer_to_result.mark_general = sum(comment.mark_general for comment in approved_comments) / len( -# approved_comments -# ) -# lecturer_to_result.mark_weighted = calc_weighted_mark( -# lecturer_to_result.mark_general, len(approved_comments), mean_mark_general -# ) -# if approved_comments: -# lecturer_to_result.subjects = list({comment.subject for comment in approved_comments}) -# result.lecturers.append(lecturer_to_result) -# return result - - -# //////////////// Мое переписывание ручки @lecturer.get("", response_model=LecturerGetAll) async def get_lecturers( lecturer_filter = FilterDepends(LecturersFilter), limit: int = 10, offset: int = 0, info: list[Literal["comments", "mark"]] = Query(default=[]), - # order_by: str = Query( - # enum=["mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"], - # default="mark_weighted", - # ), mark: float = Query(default=None, ge=-2, le=2), - # subject: str = Query(''),lecturers_query - # name: str = Query(''), - # asc_order: bool = False, ) -> LecturerGetAll: """ `limit` - максимальное количество возвращаемых преподавателей @@ -218,15 +110,13 @@ async def get_lecturers( больше, чем переданный 'mark'. """ lecturers_query = lecturer_filter.filter( - Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) # TODO: потестить без части с outerjoin. + Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) ) lecturers_query = lecturer_filter.sort(lecturers_query) lecturers_query = lecturer_filter.sort(lecturers_query) lecturers = lecturers_query.offset(offset).limit(limit).all() lecturers_count = lecturers_query.group_by(Lecturer.id).count() - # if not lecturers: - # raise ObjectNotFound(Lecturer, 'all') result = LecturerGetAll(limit=limit, offset=offset, total=lecturers_count) if "mark" in info: mean_mark_general = Lecturer.mean_mark_general() @@ -269,7 +159,6 @@ async def get_lecturers( if len(result.lecturers) == 0: raise ObjectNotFound(Lecturer, 'all') return result -# //////////////// @lecturer.patch("/{id}", response_model=LecturerGet) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index d6cb3fd..2e560cb 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -193,7 +193,6 @@ def validate_order_by(cls, value, field: ValidationInfo): return value @field_validator('order_by', mode='before') - @classmethod def check_order_param(cls, value: str) -> str: """Проверяет, что значение поля (без +/-) входит в список возможных.""" allowed_ordering = {"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"} @@ -204,30 +203,13 @@ def check_order_param(cls, value: str) -> str: raise ValueError(f'"order_by"-field must contain value from {allowed_ordering}.') def filter(self, query: Query) -> Query: - # query = super().filter(query) # FIXME: стоит ли оставлять классическое поведение? У нас ведь не предусмотрены такие фильтрации... if self.subject: query = query.filter(self.Constants.model.search_by_subject(self.subject)) if self.name: query = query.filter(self.Constants.model.search_by_name(self.name)) return query - - # if self.subject: - # subject = self.subject.lower() - # query.filter(func.lower(Comment.subject).contains(subject)) - # if self.name: - # full_name = self.name.split(' ') - # for name in full_name: - # name = name.lower() - # query.filter( - # or_( - # func.lower(self.Constants.model.first_name).contains(name), - # func.lower(self.Constants.model.middle_name).contains(name), - # func.lower(self.Constants.model.last_name).contains(name) - # ) - # ) - def sort(self, query: Query) -> Query: # FIXME: почему-то при добавлении знака к order_by-параметру, все ломается! - # print(f'{self.ordering_values=}') + def sort(self, query: Query) -> Query: if not self.ordering_values: return query elif len(self.ordering_values) > 1: diff --git a/tests/conftest.py b/tests/conftest.py index 88687b6..993356d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,6 +149,7 @@ def lecturers_with_comments(dbsession, lecturers): with 6 comments to non-deleted lecturers 4 approved and one dismissed and one pending. Two of them have alike names. Two of them have a different user_id. + One of them have a different subject. """ comments_data = [ (lecturers[0].id, 9990, 'test_subject', ReviewStatus.APPROVED, 1, 1, 1), @@ -168,7 +169,7 @@ def lecturers_with_comments(dbsession, lecturers): (lecturers[2].id, 9990, 'test_subject2', ReviewStatus.DISMISSED, 2, 2, 2), (lecturers[2].id, 9990, 'test_subject2', ReviewStatus.PENDING, -2, -2, -2), (lecturers[2].id, 9991, 'test_subject11', ReviewStatus.APPROVED, 1, 1, 1), - (lecturers[2].id, 9992, 'test_subject12', ReviewStatus.APPROVED, 0, 0, 0), + (lecturers[2].id, 9992, 'test_subject13', ReviewStatus.APPROVED, 0, 0, 0), ] comments = [] diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index 95d5aca..aa844dd 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -2,9 +2,12 @@ import pytest from starlette import status +from sqlalchemy import and_, select, func +from fastapi_sqlalchemy import db -from rating_api.models import Lecturer +from rating_api.models import Lecturer, Comment, ReviewStatus from rating_api.settings import get_settings +from rating_api.utils.mark import calc_weighted_mark logger = logging.getLogger(__name__) @@ -96,6 +99,198 @@ def test_get_lecturers_by_name(client, lecturers, query, total, response_status) assert json_response["lecturers"][0]["first_name"] == lecturers[0].first_name +@pytest.mark.usefixtures('lecturers_with_comments') +@pytest.mark.parametrize( + 'query, response_status', + [ + ({'subject': 'test_subject'}, status.HTTP_200_OK), + ({'subject': 'test_subject13'}, status.HTTP_200_OK), + ({'subject': 'test_subject666'}, status.HTTP_404_NOT_FOUND), + ], + ids=[ + 'get_all', + 'get_some', + 'wrong_subject', + ] +) +def test_get_lecturers_by_subject(client, dbsession, query, response_status): + """ + Проверка, что при передаче subject возвращаются только лекторы, + в одобренных комментариях к которым есть поле subject, совпадающее с переданным. + """ + resp = client.get(f'{url}', params=query) + assert resp.status_code == response_status + if response_status == status.HTTP_200_OK: + db_lecturers = { + comment.lecturer_id for comment in + Comment.query(session=dbsession).filter( + and_(Comment.review_status == ReviewStatus.APPROVED, Comment.subject == query['subject']) + ) + } + resp_lecturers = {lecturer['id'] for lecturer in resp.json()['lecturers']} + assert resp_lecturers == db_lecturers + + +@pytest.mark.usefixtures('lecturers_with_comments') +@pytest.mark.parametrize( + 'query, response_status', + [ + ({'mark': -2}, status.HTTP_200_OK), + ({'mark': 0}, status.HTTP_200_OK), + ({'mark': 2}, status.HTTP_404_NOT_FOUND), + ({'mark': -3}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ({'mark': 3}, status.HTTP_422_UNPROCESSABLE_ENTITY) + ], + ids=[ + 'get_all', + 'get_some', + 'get_nothing', + 'under_min', + 'above_max' + ] +) +def test_get_lecturers_by_mark(client, dbsession, query, response_status): + """ + Проверка, что при передаче mark возвращаются только лекторы + со средним mark_general (по комментариям) не меньше, чем переданный mark. + """ + resp = client.get(f'{url}', params=query) + assert resp.status_code == response_status + if response_status == status.HTTP_200_OK: + res = dbsession.execute( + (select(Lecturer.id.label('lecturer'), func.avg(Comment.mark_general).label('avg')) + .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .group_by(Lecturer.id) + .having(func.avg(Comment.mark_general) >= query['mark'])) + ).all() + resp_lecturers = {lecturer['id'] for lecturer in resp.json()['lecturers']} + db_lecturers = {req[0] for req in res} + assert resp_lecturers == db_lecturers, 'Убедитесь, что все подходящие лекторы отправляются пользователю!' + + +@pytest.mark.usefixtures('lecturers_with_comments') +@pytest.mark.parametrize( + 'query, response_status', + [ + ({'info': ['comments', 'mark']}, status.HTTP_200_OK), + ({'info': ['comments']}, status.HTTP_200_OK), + ({'info': ['mark']}, status.HTTP_200_OK), + ({'info': []}, status.HTTP_200_OK), + ({'info': {}}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ({'info': ['pupupu']}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ], + ids=[ + "comments_and_marks", + "only_comments", + "only_marks", + "no_info", + "invalid_iterator", + "invalid_param" + ] +) +def test_get_lecturers_by_info(client, dbsession, query, response_status): + """ + Проверка, что при передаче info разного состава возвращаются лекторы + с полями комментариев и/или оценок на основе них для каждого лектора. + """ + resp = client.get(f'{url}', params=query) + assert resp.status_code == response_status + if response_status == status.HTTP_200_OK: + if 'mark' in query['info']: + db_res = dbsession.execute( + (select( + Lecturer.id.label('lecturer'), + func.avg(Comment.mark_freebie).label('avg_freebie'), + func.avg(Comment.mark_kindness).label('avg_kindness'), + func.avg(Comment.mark_clarity).label('avg_clarity'), + func.avg(Comment.mark_general).label('avg_general'), + ) + .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .group_by(Lecturer.id)) + ).all() + with db(): + mean_mark_general = Lecturer.mean_mark_general() + db_lecturers = { + (*lecturer, + calc_weighted_mark( + float(lecturer[-1]), + Comment.query(session=dbsession).filter(and_(Comment.review_status == ReviewStatus.APPROVED, Comment.lecturer_id == lecturer[0])).count(), + mean_mark_general)) + for lecturer in db_res + } + resp_lecturers = { + (lecturer['id'], lecturer['mark_freebie'], lecturer['mark_kindness'], lecturer['mark_clarity'], lecturer['mark_general'], lecturer['mark_weighted']) + for lecturer in resp.json()['lecturers'] + } + assert resp_lecturers == db_lecturers + if 'comments' in query['info']: + db_res = dbsession.execute( + (select( + Lecturer.id.label('lecturer'), + func.count(Comment.uuid) + ) + .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .group_by(Lecturer.id)) + ).all() + db_lecturers = {*db_res} + assert len(resp.json()['lecturers']) == len(db_lecturers) + for lecturer in resp.json()['lecturers']: + assert (lecturer['id'], len(lecturer['comments'])) in db_lecturers + + +@pytest.mark.usefixtures('lecturers_with_comments') +@pytest.mark.parametrize( + 'query, response_status', + [ + ({'order_by': 'mark_kindness'}, status.HTTP_200_OK), + ({}, status.HTTP_200_OK), + ({'order_by': '+mark_freebie'}, status.HTTP_200_OK), + ({'order_by': '-mark_clarity'}, status.HTTP_200_OK), + ({'order_by': 'pupupu'}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ({'order_by': 'mark_kindness,mark_freebie'}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ], + ids=[ + "valid", + "valid_default", + "valid_plus", + "valid_minus", + "invalid_param", + "invalid_many_params" + ] +) +def test_get_lecturers_order_by(client, dbsession, query, response_status): + """ + Проверка, что при передаче (или нет) параметра order_by возвращаемый + список лекторов верно сортируется. + """ + resp = client.get(f'{url}', params=query) + assert resp.status_code == response_status + if response_status == status.HTTP_200_OK: + if 'order_by' not in query: + field_name = 'mark_weighted' + asc_order = True + elif query['order_by'].startswith('+'): + field_name = query['order_by'][1:] + asc_order = True + elif query['order_by'].startswith('-'): + field_name = query['order_by'][1:] + asc_order = False + else: + field_name = query['order_by'] + asc_order = True + with db(): + db_res = ( + Lecturer.query(session=dbsession) + .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .group_by(Lecturer.id) + .order_by(*Lecturer.order_by_mark(field_name, asc_order)) + .all() + ) + db_lecturers = [lecturer.id for lecturer in db_res] + resp_lecturers = [lecturer['id'] for lecturer in resp.json()['lecturers']] + assert resp_lecturers == db_lecturers + + @pytest.mark.parametrize( 'body,response_status', [ From e18f8570323d6f02705fb0879b3ad267142f269a Mon Sep 17 00:00:00 2001 From: Umiacha Date: Wed, 20 Aug 2025 00:56:29 +0300 Subject: [PATCH 06/12] DRY models. --- rating_api/schemas/models.py | 79 +++++------------------------------- 1 file changed, 10 insertions(+), 69 deletions(-) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index 2e560cb..ca9bc2e 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -28,57 +28,12 @@ class CommentGet(Base): dislike_count: int -class CommentGetWithStatus(Base): - uuid: UUID - user_id: int | None = None - create_ts: datetime.datetime - update_ts: datetime.datetime - subject: str | None = None - text: str - mark_kindness: int - mark_freebie: int - mark_clarity: int - mark_general: float - lecturer_id: int +class CommentGetWithStatus(CommentGet): review_status: ReviewStatus - like_count: int - dislike_count: int -class CommentGetWithAllInfo(Base): - uuid: UUID - user_id: int | None = None - create_ts: datetime.datetime - update_ts: datetime.datetime - subject: str | None = None - text: str - mark_kindness: int - mark_freebie: int - mark_clarity: int - mark_general: float - lecturer_id: int - review_status: ReviewStatus +class CommentGetWithAllInfo(CommentGetWithStatus): approved_by: int | None = None - like_count: int - dislike_count: int - - -class CommentPost(Base): - subject: str - text: str - create_ts: datetime.datetime | None = None - update_ts: datetime.datetime | None = None - mark_kindness: int - mark_freebie: int - mark_clarity: int - is_anonymous: bool = True - - @field_validator('mark_kindness', 'mark_freebie', 'mark_clarity') - @classmethod - def validate_mark(cls, value): - if value not in [-2, -1, 0, 1, 2]: - raise WrongMark() - return value class CommentUpdate(Base): @@ -96,22 +51,16 @@ def validate_mark(cls, value): return value -class CommentImport(Base): - lecturer_id: int - subject: str | None = None - text: str +class CommentPost(CommentUpdate): create_ts: datetime.datetime | None = None update_ts: datetime.datetime | None = None - mark_kindness: int - mark_freebie: int - mark_clarity: int + is_anonymous: bool = True - @field_validator('mark_kindness', 'mark_freebie', 'mark_clarity') - @classmethod - def validate_mark(cls, value): - if value not in [-2, -1, 0, 1, 2]: - raise WrongMark() - return value + +class CommentImport(CommentUpdate): + lecturer_id: int + create_ts: datetime.datetime | None = None + update_ts: datetime.datetime | None = None class CommentImportAll(Base): @@ -127,16 +76,10 @@ class CommentGetAll(Base): class CommentGetAllWithStatus(Base): comments: list[CommentGetWithStatus] = [] - limit: int - offset: int - total: int class CommentGetAllWithAllInfo(Base): comments: list[CommentGetWithAllInfo] = [] - limit: int - offset: int - total: int class LecturerUserCommentPost(Base): @@ -175,12 +118,10 @@ class LecturerPost(Base): timetable_id: int | None = None -class LecturerPatch(Base): +class LecturerPatch(LecturerPost): first_name: str | None = None last_name: str | None = None middle_name: str | None = None - avatar_link: str | None = None - timetable_id: int | None = None class LecturersFilter(Filter): From 477b9f5b23bb6c4b61e44d858d4cda30a5c6f166 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Wed, 20 Aug 2025 01:04:42 +0300 Subject: [PATCH 07/12] Format code. --- rating_api/routes/lecturer.py | 19 ++++++++++++++----- rating_api/schemas/models.py | 24 ++++++++++++++++-------- tests/test_routes/test_lecturer.py | 16 +++++++++++++--- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index 2344cba..aace3ee 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -2,14 +2,21 @@ from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, Query +from fastapi_filter import FilterDepends from fastapi_sqlalchemy import db from sqlalchemy import and_ -from fastapi_filter import FilterDepends from rating_api.exceptions import AlreadyExists, ObjectNotFound from rating_api.models import Comment, Lecturer, LecturerUserComment, ReviewStatus from rating_api.schemas.base import StatusResponseModel -from rating_api.schemas.models import CommentGet, LecturerGet, LecturerGetAll, LecturerPatch, LecturerPost, LecturersFilter +from rating_api.schemas.models import ( + CommentGet, + LecturerGet, + LecturerGetAll, + LecturerPatch, + LecturerPost, + LecturersFilter, +) from rating_api.utils.mark import calc_weighted_mark @@ -77,7 +84,7 @@ async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query( @lecturer.get("", response_model=LecturerGetAll) async def get_lecturers( - lecturer_filter = FilterDepends(LecturersFilter), + lecturer_filter=FilterDepends(LecturersFilter), limit: int = 10, offset: int = 0, info: list[Literal["comments", "mark"]] = Query(default=[]), @@ -129,9 +136,11 @@ async def get_lecturers( for comment in db_lecturer.comments if comment.review_status is ReviewStatus.APPROVED ] - if (mark is not None + if ( + mark is not None and approved_comments - and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark): + and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark + ): continue if "comments" in info and approved_comments: lecturer_to_result.comments = sorted( diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index ca9bc2e..45c6faf 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -1,14 +1,13 @@ import datetime +from typing import List from uuid import UUID -from typing import List, Literal, Optional from fastapi import Query -from pydantic import Field, field_validator, ValidationError, ValidationInfo from fastapi_filter.contrib.sqlalchemy import Filter -from sqlalchemy import func, or_ +from pydantic import ValidationInfo, field_validator from rating_api.exceptions import WrongMark -from rating_api.models import ReviewStatus, Lecturer, Comment +from rating_api.models import Lecturer, ReviewStatus from rating_api.schemas.base import Base @@ -127,7 +126,9 @@ class LecturerPatch(LecturerPost): class LecturersFilter(Filter): subject: str = '' name: str = '' - order_by: List[str] = ['mark_weighted',] + order_by: List[str] = [ + 'mark_weighted', + ] @field_validator("*", mode="before", check_fields=False) def validate_order_by(cls, value, field: ValidationInfo): @@ -136,7 +137,14 @@ def validate_order_by(cls, value, field: ValidationInfo): @field_validator('order_by', mode='before') def check_order_param(cls, value: str) -> str: """Проверяет, что значение поля (без +/-) входит в список возможных.""" - allowed_ordering = {"mark_weighted", "mark_kindness", "mark_freebie", "mark_clarity", "mark_general", "last_name"} + allowed_ordering = { + "mark_weighted", + "mark_kindness", + "mark_freebie", + "mark_clarity", + "mark_general", + "last_name", + } cleaned_value = value.replace("+", "").replace("-", "") if cleaned_value in allowed_ordering: return value @@ -149,7 +157,7 @@ def filter(self, query: Query) -> Query: if self.name: query = query.filter(self.Constants.model.search_by_name(self.name)) return query - + def sort(self, query: Query) -> Query: if not self.ordering_values: return query @@ -166,6 +174,6 @@ def sort(self, query: Query) -> Query: else: query = query.order_by(*self.Constants.model.order_by_name(field_name, direction)) return query - + class Constants(Filter.Constants): model = Lecturer diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index aa844dd..b9dd0b9 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -205,7 +205,10 @@ def test_get_lecturers_by_info(client, dbsession, query, response_status): func.avg(Comment.mark_clarity).label('avg_clarity'), func.avg(Comment.mark_general).label('avg_general'), ) - .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .join( + Comment, and_(Comment.review_status == ReviewStatus.APPROVED, + Lecturer.id == Comment.lecturer_id) + ) .group_by(Lecturer.id)) ).all() with db(): @@ -214,12 +217,19 @@ def test_get_lecturers_by_info(client, dbsession, query, response_status): (*lecturer, calc_weighted_mark( float(lecturer[-1]), - Comment.query(session=dbsession).filter(and_(Comment.review_status == ReviewStatus.APPROVED, Comment.lecturer_id == lecturer[0])).count(), + Comment.query(session=dbsession).filter( + and_(Comment.review_status == ReviewStatus.APPROVED, Comment.lecturer_id == lecturer[0]) + ).count(), mean_mark_general)) for lecturer in db_res } resp_lecturers = { - (lecturer['id'], lecturer['mark_freebie'], lecturer['mark_kindness'], lecturer['mark_clarity'], lecturer['mark_general'], lecturer['mark_weighted']) + (lecturer['id'], + lecturer['mark_freebie'], + lecturer['mark_kindness'], + lecturer['mark_clarity'], + lecturer['mark_general'], + lecturer['mark_weighted']) for lecturer in resp.json()['lecturers'] } assert resp_lecturers == db_lecturers From 84e4ed75742413ea86b791cfec7bfa5ebb702388 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Wed, 20 Aug 2025 03:01:58 +0300 Subject: [PATCH 08/12] Reformat code. --- tests/test_routes/test_lecturer.py | 153 ++++++++++++++--------------- 1 file changed, 73 insertions(+), 80 deletions(-) diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index b9dd0b9..82c704b 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -1,11 +1,11 @@ import logging import pytest -from starlette import status -from sqlalchemy import and_, select, func from fastapi_sqlalchemy import db +from sqlalchemy import and_, func, select +from starlette import status -from rating_api.models import Lecturer, Comment, ReviewStatus +from rating_api.models import Comment, Lecturer, ReviewStatus from rating_api.settings import get_settings from rating_api.utils.mark import calc_weighted_mark @@ -101,17 +101,17 @@ def test_get_lecturers_by_name(client, lecturers, query, total, response_status) @pytest.mark.usefixtures('lecturers_with_comments') @pytest.mark.parametrize( - 'query, response_status', - [ - ({'subject': 'test_subject'}, status.HTTP_200_OK), - ({'subject': 'test_subject13'}, status.HTTP_200_OK), - ({'subject': 'test_subject666'}, status.HTTP_404_NOT_FOUND), - ], - ids=[ - 'get_all', - 'get_some', - 'wrong_subject', - ] + 'query, response_status', + [ + ({'subject': 'test_subject'}, status.HTTP_200_OK), + ({'subject': 'test_subject13'}, status.HTTP_200_OK), + ({'subject': 'test_subject666'}, status.HTTP_404_NOT_FOUND), + ], + ids=[ + 'get_all', + 'get_some', + 'wrong_subject', + ], ) def test_get_lecturers_by_subject(client, dbsession, query, response_status): """ @@ -122,8 +122,8 @@ def test_get_lecturers_by_subject(client, dbsession, query, response_status): assert resp.status_code == response_status if response_status == status.HTTP_200_OK: db_lecturers = { - comment.lecturer_id for comment in - Comment.query(session=dbsession).filter( + comment.lecturer_id + for comment in Comment.query(session=dbsession).filter( and_(Comment.review_status == ReviewStatus.APPROVED, Comment.subject == query['subject']) ) } @@ -133,21 +133,15 @@ def test_get_lecturers_by_subject(client, dbsession, query, response_status): @pytest.mark.usefixtures('lecturers_with_comments') @pytest.mark.parametrize( - 'query, response_status', - [ - ({'mark': -2}, status.HTTP_200_OK), - ({'mark': 0}, status.HTTP_200_OK), - ({'mark': 2}, status.HTTP_404_NOT_FOUND), - ({'mark': -3}, status.HTTP_422_UNPROCESSABLE_ENTITY), - ({'mark': 3}, status.HTTP_422_UNPROCESSABLE_ENTITY) - ], - ids=[ - 'get_all', - 'get_some', - 'get_nothing', - 'under_min', - 'above_max' - ] + 'query, response_status', + [ + ({'mark': -2}, status.HTTP_200_OK), + ({'mark': 0}, status.HTTP_200_OK), + ({'mark': 2}, status.HTTP_404_NOT_FOUND), + ({'mark': -3}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ({'mark': 3}, status.HTTP_422_UNPROCESSABLE_ENTITY), + ], + ids=['get_all', 'get_some', 'get_nothing', 'under_min', 'above_max'], ) def test_get_lecturers_by_mark(client, dbsession, query, response_status): """ @@ -158,10 +152,12 @@ def test_get_lecturers_by_mark(client, dbsession, query, response_status): assert resp.status_code == response_status if response_status == status.HTTP_200_OK: res = dbsession.execute( - (select(Lecturer.id.label('lecturer'), func.avg(Comment.mark_general).label('avg')) - .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) - .group_by(Lecturer.id) - .having(func.avg(Comment.mark_general) >= query['mark'])) + ( + select(Lecturer.id.label('lecturer'), func.avg(Comment.mark_general).label('avg')) + .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) + .group_by(Lecturer.id) + .having(func.avg(Comment.mark_general) >= query['mark']) + ) ).all() resp_lecturers = {lecturer['id'] for lecturer in resp.json()['lecturers']} db_lecturers = {req[0] for req in res} @@ -179,14 +175,7 @@ def test_get_lecturers_by_mark(client, dbsession, query, response_status): ({'info': {}}, status.HTTP_422_UNPROCESSABLE_ENTITY), ({'info': ['pupupu']}, status.HTTP_422_UNPROCESSABLE_ENTITY), ], - ids=[ - "comments_and_marks", - "only_comments", - "only_marks", - "no_info", - "invalid_iterator", - "invalid_param" - ] + ids=["comments_and_marks", "only_comments", "only_marks", "no_info", "invalid_iterator", "invalid_param"], ) def test_get_lecturers_by_info(client, dbsession, query, response_status): """ @@ -198,49 +187,60 @@ def test_get_lecturers_by_info(client, dbsession, query, response_status): if response_status == status.HTTP_200_OK: if 'mark' in query['info']: db_res = dbsession.execute( - (select( - Lecturer.id.label('lecturer'), - func.avg(Comment.mark_freebie).label('avg_freebie'), - func.avg(Comment.mark_kindness).label('avg_kindness'), - func.avg(Comment.mark_clarity).label('avg_clarity'), - func.avg(Comment.mark_general).label('avg_general'), + ( + select( + Lecturer.id.label('lecturer'), + func.avg(Comment.mark_freebie).label('avg_freebie'), + func.avg(Comment.mark_kindness).label('avg_kindness'), + func.avg(Comment.mark_clarity).label('avg_clarity'), + func.avg(Comment.mark_general).label('avg_general'), + ) + .join( + Comment, + and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id), + ) + .group_by(Lecturer.id) ) - .join( - Comment, and_(Comment.review_status == ReviewStatus.APPROVED, - Lecturer.id == Comment.lecturer_id) - ) - .group_by(Lecturer.id)) ).all() with db(): mean_mark_general = Lecturer.mean_mark_general() db_lecturers = { - (*lecturer, - calc_weighted_mark( - float(lecturer[-1]), - Comment.query(session=dbsession).filter( - and_(Comment.review_status == ReviewStatus.APPROVED, Comment.lecturer_id == lecturer[0]) - ).count(), - mean_mark_general)) + ( + *lecturer, + calc_weighted_mark( + float(lecturer[-1]), + Comment.query(session=dbsession) + .filter( + and_(Comment.review_status == ReviewStatus.APPROVED, Comment.lecturer_id == lecturer[0]) + ) + .count(), + mean_mark_general, + ), + ) for lecturer in db_res } resp_lecturers = { - (lecturer['id'], - lecturer['mark_freebie'], - lecturer['mark_kindness'], - lecturer['mark_clarity'], - lecturer['mark_general'], - lecturer['mark_weighted']) + ( + lecturer['id'], + lecturer['mark_freebie'], + lecturer['mark_kindness'], + lecturer['mark_clarity'], + lecturer['mark_general'], + lecturer['mark_weighted'], + ) for lecturer in resp.json()['lecturers'] } assert resp_lecturers == db_lecturers if 'comments' in query['info']: db_res = dbsession.execute( - (select( - Lecturer.id.label('lecturer'), - func.count(Comment.uuid) + ( + select(Lecturer.id.label('lecturer'), func.count(Comment.uuid)) + .join( + Comment, + and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id), + ) + .group_by(Lecturer.id) ) - .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) - .group_by(Lecturer.id)) ).all() db_lecturers = {*db_res} assert len(resp.json()['lecturers']) == len(db_lecturers) @@ -259,14 +259,7 @@ def test_get_lecturers_by_info(client, dbsession, query, response_status): ({'order_by': 'pupupu'}, status.HTTP_422_UNPROCESSABLE_ENTITY), ({'order_by': 'mark_kindness,mark_freebie'}, status.HTTP_422_UNPROCESSABLE_ENTITY), ], - ids=[ - "valid", - "valid_default", - "valid_plus", - "valid_minus", - "invalid_param", - "invalid_many_params" - ] + ids=["valid", "valid_default", "valid_plus", "valid_minus", "invalid_param", "invalid_many_params"], ) def test_get_lecturers_order_by(client, dbsession, query, response_status): """ From 12b2373863df0ff9da2c3d4de1783e1683be7068 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Thu, 21 Aug 2025 01:47:09 +0300 Subject: [PATCH 09/12] Add tests in autoformatting. --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 8a826f9..fe8b677 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,9 @@ format: autoflake -r --in-place --remove-all-unused-imports ./migrations isort ./migrations black ./migrations + autoflake -r --in-place --remove-all-unused-imports ./tests + isort ./tests + black ./tests db: docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 From e2e4096929f3b05f59de651d8febbab777fb37d2 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Fri, 22 Aug 2025 23:29:03 +0300 Subject: [PATCH 10/12] One-level inheritance. --- rating_api/schemas/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index 45c6faf..d74e938 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -31,7 +31,8 @@ class CommentGetWithStatus(CommentGet): review_status: ReviewStatus -class CommentGetWithAllInfo(CommentGetWithStatus): +class CommentGetWithAllInfo(CommentGet): + review_status: ReviewStatus approved_by: int | None = None From 57b3c9561e69ed726c6557de1bf5d23268b3fd1b Mon Sep 17 00:00:00 2001 From: Umiacha Date: Fri, 22 Aug 2025 23:53:23 +0300 Subject: [PATCH 11/12] Expand docstring + del repeat. --- rating_api/routes/lecturer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index aace3ee..deb1dbe 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -101,6 +101,11 @@ async def get_lecturers( Если передано просто так (или с '+' в начале параметра), то сортирует по возрастанию С '-' в начале -- по убыванию. + *Пример запросов с этим параметром*: + - `...?order_by=-mark_kindness` + - `...?order_by=mark_freebie` + - `...?order_by=+mark_freebie` (эквивалентно 2ому пункту) + `info` - возможные значения `'comments'`, `'mark'`. Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. @@ -120,7 +125,6 @@ async def get_lecturers( Lecturer.query(session=db.session).outerjoin(Lecturer.comments).group_by(Lecturer.id) ) lecturers_query = lecturer_filter.sort(lecturers_query) - lecturers_query = lecturer_filter.sort(lecturers_query) lecturers = lecturers_query.offset(offset).limit(limit).all() lecturers_count = lecturers_query.group_by(Lecturer.id).count() From 7df7169c1e73f7756c734179ad97832c4422d572 Mon Sep 17 00:00:00 2001 From: Umiacha Date: Sat, 23 Aug 2025 16:27:19 +0300 Subject: [PATCH 12/12] Del await in comments + add soft comparison. --- rating_api/routes/comment.py | 2 +- rating_api/routes/lecturer.py | 2 +- tests/test_routes/test_lecturer.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index 7f9727e..d24a130 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -126,7 +126,7 @@ async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depen else: give_achievement = False if give_achievement: - await session.post( + session.post( settings.API_URL + f"achievement/achievement/{settings.FIRST_COMMENT_ACHIEVEMENT_ID}/reciever/{user.get('id'):}", headers={"Accept": "application/json", "Authorization": settings.ACHIEVEMENT_GIVE_TOKEN}, diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index deb1dbe..7ec9f70 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -143,7 +143,7 @@ async def get_lecturers( if ( mark is not None and approved_comments - and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) < mark + and sum(comment.mark_general for comment in approved_comments) / len(approved_comments) <= mark ): continue if "comments" in info and approved_comments: diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index 82c704b..8c42600 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -156,7 +156,7 @@ def test_get_lecturers_by_mark(client, dbsession, query, response_status): select(Lecturer.id.label('lecturer'), func.avg(Comment.mark_general).label('avg')) .join(Comment, and_(Comment.review_status == ReviewStatus.APPROVED, Lecturer.id == Comment.lecturer_id)) .group_by(Lecturer.id) - .having(func.avg(Comment.mark_general) >= query['mark']) + .having(func.avg(Comment.mark_general) > query['mark']) ) ).all() resp_lecturers = {lecturer['id'] for lecturer in resp.json()['lecturers']}