diff --git a/migrations/versions/beb11fd89531_lecturer_rating.py b/migrations/versions/beb11fd89531_lecturer_rating.py new file mode 100644 index 0000000..075f8e6 --- /dev/null +++ b/migrations/versions/beb11fd89531_lecturer_rating.py @@ -0,0 +1,165 @@ +"""lecturer-rating + +Revision ID: beb11fd89531 +Revises: fc7cb93684e0 +Create Date: 2025-08-25 14:59:47.363354 + +""" + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'beb11fd89531' +down_revision = 'fc7cb93684e0' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + 'lecturer', + sa.Column( + 'mark_weighted', + sa.Float(), + server_default='0.0', + nullable=False, + comment='Взвешенная оценка преподавателя, посчитана в dwh', + ), + ) + op.add_column( + 'lecturer', + sa.Column( + 'mark_kindness_weighted', + sa.Float(), + server_default='0.0', + nullable=False, + comment='Взвешенная оценка доброты, посчитана в dwh', + ), + ) + op.add_column( + 'lecturer', + sa.Column( + 'mark_clarity_weighted', + sa.Float(), + server_default='0.0', + nullable=False, + comment='Взвешенная оценка понятности, посчитана в dwh', + ), + ) + op.add_column( + 'lecturer', + sa.Column( + 'mark_freebie_weighted', + sa.Float(), + server_default='0.0', + nullable=False, + comment='Взвешенная оценка халявности, посчитана в dwh', + ), + ) + op.add_column( + 'lecturer', + sa.Column( + 'rank', sa.Integer(), server_default='0', nullable=False, comment='Место в рейтинге, посчитана в dwh' + ), + ) + op.add_column( + 'lecturer', + sa.Column( + 'rank_update_ts', + sa.DateTime(), + server_default=sa.func.now(), + nullable=False, + comment='Время обновления записи', + ), + ) + op.alter_column( + 'lecturer', + 'id', + existing_type=sa.INTEGER(), + comment='Идентификатор преподавателя', + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('lecturer_id_seq'::regclass)"), + ) + op.alter_column( + 'lecturer', 'first_name', existing_type=sa.VARCHAR(), comment='Имя препода', existing_nullable=False + ) + op.alter_column( + 'lecturer', 'last_name', existing_type=sa.VARCHAR(), comment='Фамилия препода', existing_nullable=False + ) + op.alter_column( + 'lecturer', 'middle_name', existing_type=sa.VARCHAR(), comment='Отчество препода', existing_nullable=False + ) + op.alter_column( + 'lecturer', 'avatar_link', existing_type=sa.VARCHAR(), comment='Ссылка на аву препода', existing_nullable=True + ) + op.alter_column( + 'lecturer', + 'is_deleted', + existing_type=sa.BOOLEAN(), + comment='Идентификатор софт делита', + existing_nullable=False, + existing_server_default=sa.text('false'), + ) + + +def downgrade(): + op.alter_column( + 'lecturer', + 'is_deleted', + existing_type=sa.BOOLEAN(), + comment=None, + existing_comment='Идентификатор софт делита', + existing_nullable=False, + existing_server_default=sa.text('false'), + ) + op.alter_column( + 'lecturer', + 'avatar_link', + existing_type=sa.VARCHAR(), + comment=None, + existing_comment='Ссылка на аву препода', + existing_nullable=True, + ) + op.alter_column( + 'lecturer', + 'middle_name', + existing_type=sa.VARCHAR(), + comment=None, + existing_comment='Отчество препода', + existing_nullable=False, + ) + op.alter_column( + 'lecturer', + 'last_name', + existing_type=sa.VARCHAR(), + comment=None, + existing_comment='Фамилия препода', + existing_nullable=False, + ) + op.alter_column( + 'lecturer', + 'first_name', + existing_type=sa.VARCHAR(), + comment=None, + existing_comment='Имя препода', + existing_nullable=False, + ) + op.alter_column( + 'lecturer', + 'id', + existing_type=sa.INTEGER(), + comment=None, + existing_comment='Идентификатор преподавателя', + existing_nullable=False, + autoincrement=True, + existing_server_default=sa.text("nextval('lecturer_id_seq'::regclass)"), + ) + op.drop_column('lecturer', 'rank_update_ts') + op.drop_column('lecturer', 'rank') + op.drop_column('lecturer', 'mark_freebie_weighted') + op.drop_column('lecturer', 'mark_clarity_weighted') + op.drop_column('lecturer', 'mark_kindness_weighted') + op.drop_column('lecturer', 'mark_weighted') diff --git a/rating_api/models/db.py b/rating_api/models/db.py index 6ad4a52..18dae0b 100644 --- a/rating_api/models/db.py +++ b/rating_api/models/db.py @@ -13,6 +13,7 @@ ) from sqlalchemy import Enum as DbEnum from sqlalchemy import ( + Float, ForeignKey, Integer, String, @@ -43,14 +44,43 @@ class ReviewStatus(str, Enum): class Lecturer(BaseDbModel): - id: Mapped[int] = mapped_column(Integer, primary_key=True) - first_name: Mapped[str] = mapped_column(String, nullable=False) - last_name: Mapped[str] = mapped_column(String, nullable=False) - middle_name: Mapped[str] = mapped_column(String, nullable=False) - avatar_link: Mapped[str] = mapped_column(String, nullable=True) + id: Mapped[int] = mapped_column(Integer, primary_key=True, comment="Идентификатор преподавателя") + first_name: Mapped[str] = mapped_column(String, nullable=False, comment="Имя препода") + last_name: Mapped[str] = mapped_column(String, nullable=False, comment="Фамилия препода") + middle_name: Mapped[str] = mapped_column(String, nullable=False, comment="Отчество препода") + avatar_link: Mapped[str] = mapped_column(String, nullable=True, comment="Ссылка на аву препода") timetable_id: Mapped[int] comments: Mapped[list[Comment]] = relationship("Comment", back_populates="lecturer") - is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + mark_weighted: Mapped[float] = mapped_column( + Float, + nullable=False, + server_default='0.0', + default=0, + comment="Взвешенная оценка преподавателя, посчитана в dwh", + ) + mark_kindness_weighted: Mapped[float] = mapped_column( + Float, nullable=False, server_default='0.0', default=0, comment="Взвешенная оценка доброты, посчитана в dwh" + ) + mark_clarity_weighted: Mapped[float] = mapped_column( + Float, nullable=False, server_default='0.0', default=0, comment="Взвешенная оценка понятности, посчитана в dwh" + ) + mark_freebie_weighted: Mapped[float] = mapped_column( + Float, nullable=False, server_default='0.0', default=0, comment="Взвешенная оценка халявности, посчитана в dwh" + ) + rank: Mapped[int] = mapped_column( + Integer, nullable=False, server_default='0', default=0, comment="Место в рейтинге, посчитана в dwh" + ) + rank_update_ts: Mapped[datetime.datetime] = mapped_column( + DateTime, + nullable=False, + server_default=func.now(), + default=datetime.datetime.now(), + comment="Время обновления записи", + ) + + is_deleted: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, comment="Идентификатор софт делита" + ) @hybrid_method def search_by_name(self, query: str) -> bool: @@ -81,11 +111,15 @@ def order_by_mark( self, query: str, asc_order: bool ) -> tuple[UnaryExpression[float], InstrumentedAttribute, InstrumentedAttribute]: if "mark_weighted" in query: - comments_num = func.count(self.comments).filter(Comment.review_status == ReviewStatus.APPROVED) - lecturer_mark_general = func.avg(Comment.mark_general).filter( - Comment.review_status == ReviewStatus.APPROVED - ) - expression = calc_weighted_mark(lecturer_mark_general, comments_num, Lecturer.mean_mark_general()) + expression = self.mark_weighted + elif "mark_clarity_weighted" in query: + expression = self.mark_clarity_weighted + elif "mark_freebie_weighted" in query: + expression = self.mark_freebie_weighted + elif "mark_kindness_weighted" in query: + expression = self.mark_kindness_weighted + elif "rank" in query: + expression = self.rank else: expression = func.avg(getattr(Comment, query)).filter(Comment.review_status == ReviewStatus.APPROVED) if not asc_order: @@ -98,21 +132,6 @@ def order_by_name( ) -> tuple[UnaryExpression[str] | InstrumentedAttribute, InstrumentedAttribute]: return (getattr(Lecturer, query) if asc_order else getattr(Lecturer, query).desc()), Lecturer.id - @staticmethod - def mean_mark_general() -> float: - mark_general_rows = ( - db.session.query(func.avg(Comment.mark_general)) - .filter(Comment.review_status == ReviewStatus.APPROVED) - .group_by(Comment.lecturer_id) - .all() - ) - mean_mark_general = float( - sum(mark_general_row[0] for mark_general_row in mark_general_rows) / len(mark_general_rows) - if len(mark_general_rows) != 0 - else 0 - ) - return mean_mark_general - class Comment(BaseDbModel): uuid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index ff882bd..c650020 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -1,7 +1,9 @@ +import datetime from typing import Literal from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, Query +from fastapi.exceptions import ValidationException from fastapi_filter import FilterDepends from fastapi_sqlalchemy import db from sqlalchemy import and_ @@ -16,6 +18,7 @@ LecturerPatch, LecturerPost, LecturersFilter, + LecturerWithRank, ) from rating_api.utils.mark import calc_weighted_mark @@ -43,17 +46,57 @@ async def create_lecturer( raise AlreadyExists(Lecturer, lecturer_info.timetable_id) +@lecturer.patch("/import_rating", response_model=dict) +async def update_lecturer_rating( + lecturer_rank_info: list[LecturerWithRank], + _=Depends(UnionAuth(scopes=["rating.lecturer.update_rating"], allow_none=False, auto_error=True)), +) -> dict: + """ + Обновляет рейтинг преподавателя в базе данных RatingAPI + """ + updated_lecturers = [] + response = { + "updated": 0, + "failed": 0, + "updated_id": [], + "failed_id": [], + } + for lecturer_rank in lecturer_rank_info: + success_fl = True + try: + LecturerWithRank.model_validate(lecturer_rank) + except ValidationException: + success_fl = False + + lecturer_rank_dumped = lecturer_rank.model_dump() + lecturer_rank_dumped["update_ts"] = datetime.datetime.now(tz=datetime.timezone.utc) + + lecturer_id = lecturer_rank_dumped.pop("id") + + if Lecturer.get(id=lecturer_id, session=db.session): + updated_lecturers.append(Lecturer.update(id=lecturer_id, session=db.session, **lecturer_rank_dumped)) + else: + success_fl = False + + if success_fl: + response["updated"] += 1 + response["updated_id"].append(lecturer_id) + else: + response["failed"] += 1 + response["failed_id"].append(lecturer_id) + + return response + + @lecturer.get("/{id}", response_model=LecturerGet) -async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query(default=[])) -> LecturerGet: +async def get_lecturer(id: int, info: list[Literal["comments"]] = Query(default=[])) -> LecturerGet: """ Scopes: `["rating.lecturer.read"]` Возвращает преподавателя по его ID в базе данных RatingAPI - *QUERY* `info: string` - возможные значения `'comments'`, `'mark'`. + *QUERY* `info: string` - возможные значения `'comments'`. Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. - Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. - Subject лектора возвращшается либо из базы данных, либо из любого аппрувнутого комментария """ lecturer: Lecturer = Lecturer.query(session=db.session).filter(Lecturer.id == id).one_or_none() @@ -69,14 +112,6 @@ async def get_lecturer(id: int, info: list[Literal["comments", "mark"]] = Query( ] if "comments" in info and approved_comments: result.comments = sorted(approved_comments, key=lambda comment: comment.create_ts, reverse=True) - if "mark" in info and approved_comments: - result.mark_freebie = sum(comment.mark_freebie for comment in approved_comments) / len(approved_comments) - result.mark_kindness = sum(comment.mark_kindness for comment in approved_comments) / len(approved_comments) - result.mark_clarity = sum(comment.mark_clarity for comment in approved_comments) / len(approved_comments) - result.mark_general = sum(comment.mark_general for comment in approved_comments) / len(approved_comments) - result.mark_weighted = calc_weighted_mark( - result.mark_general, len(approved_comments), Lecturer.mean_mark_general() - ) if approved_comments: result.subjects = list({comment.subject for comment in approved_comments}) return result @@ -87,7 +122,7 @@ async def get_lecturers( lecturer_filter=FilterDepends(LecturersFilter), limit: int = 10, offset: int = 0, - info: list[Literal["comments", "mark"]] = Query(default=[]), + info: list[Literal["comments"]] = Query(default=[]), mark: float = Query(default=None, ge=-2, le=2), ) -> LecturerGetAll: """ @@ -106,9 +141,8 @@ async def get_lecturers( - `...?order_by=mark_freebie` - `...?order_by=+mark_freebie` (эквивалентно 2ому пункту) - `info` - возможные значения `'comments'`, `'mark'`. + `info` - возможные значения `'comments'`. Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. - Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. `subject` Если передано `subject` - возвращает всех преподавателей, для которых переданное значение совпадает с одним из их предметов преподавания. @@ -129,8 +163,6 @@ async def get_lecturers( lecturers_count = lecturers_query.group_by(Lecturer.id).count() 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 @@ -150,22 +182,6 @@ async def get_lecturers( 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) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index d74e938..caf1586 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -95,14 +95,30 @@ class LecturerGet(Base): avatar_link: str | None = None subjects: list[str] | None = None timetable_id: int - mark_kindness: float | None = None - mark_freebie: float | None = None - mark_clarity: float | None = None - mark_general: float | None = None + mark_kindness_weighted: float | None = None + mark_clarity_weighted: float | None = None + mark_freebie_weighted: float | None = None mark_weighted: float | None = None + rank: int | None = None + update_ts: datetime.datetime | None = None comments: list[CommentGet] | None = None +class LecturerWithRank(Base): + id: int + first_name: str + last_name: str + middle_name: str + avatar_link: str | None = None + timetable_id: int + mark_weighted: float + mark_kindness_weighted: float + mark_clarity_weighted: float + mark_freebie_weighted: float + rank: int + update_ts: datetime.datetime | None = None + + class LecturerGetAll(Base): lecturers: list[LecturerGet] = [] limit: int diff --git a/tests/conftest.py b/tests/conftest.py index 32bc43d..3742f8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,25 +184,25 @@ def lecturers(dbsession): Creates 4 lecturers(one with flag is_deleted=True) """ lecturers_data = [ - ("test_fname1", "test_lname1", "test_mname1", 9900), - ("test_fname2", "test_lname2", "test_mname2", 9901), - ("Bibka", "Bobka", "Bobkovich", 9902), + (1, "test_fname1", "test_lname1", "test_mname1", 9900), + (2, "test_fname2", "test_lname2", "test_mname2", 9901), + (3, "Bibka", "Bobka", "Bobkovich", 9902), ] lecturers = [ - Lecturer(first_name=fname, last_name=lname, middle_name=mname, timetable_id=timetable_id) - for fname, lname, mname, timetable_id in lecturers_data + Lecturer(id=lecturer_id, first_name=fname, last_name=lname, middle_name=mname, timetable_id=timetable_id) + for lecturer_id, fname, lname, mname, timetable_id in lecturers_data ] lecturers.append( - Lecturer(first_name='test_fname3', last_name='test_lname3', middle_name='test_mname3', timetable_id=9903) + Lecturer(id=4, first_name='test_fname3', last_name='test_lname3', middle_name='test_mname3', timetable_id=9903) ) lecturers[-1].is_deleted = True for lecturer in lecturers: dbsession.add(lecturer) dbsession.commit() yield lecturers + for lecturer in lecturers: - dbsession.refresh(lecturer) for row in lecturer.comments: dbsession.delete(row) lecturer_user_comments = dbsession.query(LecturerUserComment).filter( diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index 8c42600..6408c85 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -1,4 +1,6 @@ +import datetime import logging +from unittest.mock import patch import pytest from fastapi_sqlalchemy import db @@ -52,29 +54,32 @@ def test_get_lecturer(client, dbsession, lecturers, lecturer_n, response_status) assert get_response.status_code == response_status if response_status == status.HTTP_200_OK: json_response = get_response.json() - assert json_response["mark_kindness"] is None - assert json_response["mark_freebie"] is None - assert json_response["mark_clarity"] is None - assert json_response["mark_general"] is None + assert json_response["mark_kindness_weighted"] == 0.0 + assert json_response["mark_freebie_weighted"] == 0.0 + assert json_response["mark_clarity_weighted"] == 0.0 + assert json_response["mark_weighted"] == 0.0 + assert json_response["rank"] == 0 assert json_response["comments"] is None @pytest.mark.parametrize( - 'lecturer_n,mark_kindness,mark_freebie,mark_clarity,mark_general', + 'lecturer_n,mark_kindness_weighted,mark_freebie_weighted,mark_clarity_weighted,mark_weighted', [(0, 1.5, 1.5, 1.5, 1.5), (1, 0, 0, 0, 0), (2, 0.5, 0.5, 0.5, 0.5)], ) def test_get_lecturer_with_comments( - client, lecturers_with_comments, lecturer_n, mark_kindness, mark_freebie, mark_clarity, mark_general + client, + lecturers_with_comments, + lecturer_n, + mark_kindness_weighted, + mark_freebie_weighted, + mark_clarity_weighted, + mark_weighted, ): lecturers, comments = lecturers_with_comments - query = {"info": ['comments', 'mark']} + query = {"info": ['comments']} response = client.get(f'{url}/{lecturers[lecturer_n].id}', params=query) assert response.status_code == status.HTTP_200_OK json_response = response.json() - assert json_response["mark_kindness"] == mark_kindness - assert json_response["mark_freebie"] == mark_freebie - assert json_response["mark_clarity"] == mark_clarity - assert json_response["mark_general"] == mark_general assert comments[lecturer_n * 6 + 0].subject in json_response["subjects"] assert comments[lecturer_n * 6 + 1].subject in json_response["subjects"] assert comments[lecturer_n * 6 + 2].subject not in json_response["subjects"] @@ -168,14 +173,13 @@ def test_get_lecturers_by_mark(client, dbsession, query, response_status): @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': ['comments']}, 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"], + ids=["comments", "comments", "no_info", "invalid_iterator", "invalid_param"], ) def test_get_lecturers_by_info(client, dbsession, query, response_status): """ @@ -185,52 +189,6 @@ def test_get_lecturers_by_info(client, dbsession, query, response_status): 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( ( @@ -357,3 +315,51 @@ def test_delete_lecturer(client, dbsession, lecturers_with_comments): # trying to get deleted response = client.get(f'{url}/{lecturers[0].id}') assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.usefixtures('lecturers') +@pytest.mark.parametrize( + 'body, response_status', + [ + ( + { + "id": 1, + "first_name": "test_fname1", + "last_name": "test_lname1", + "middle_name": "test_mname1", + "timetable_id": 9900, + "mark_weighted": 4.5, + "mark_kindness_weighted": 4.0, + "mark_clarity_weighted": 4.8, + "mark_freebie_weighted": 3.9, + "rank": 12, + }, + status.HTTP_200_OK, + ), + ( + { + "id": 2, + "first_name": "test_fname2", + "last_name": "test_lname2", + "middle_name": "test_mname2", + "avatar_link": None, + "timetable_id": 9901, + "mark_weighted": 4.5, + "mark_kindness_weighted": 4.0, + "mark_clarity_weighted": 4.8, + "mark_freebie_weighted": 3.9, + "rank": 5, + }, + status.HTTP_200_OK, + ), + ], +) +def test_lecturer_rating_update(client, dbsession, body, response_status): + response = client.patch('/lecturer/import_rating', json=[body]) + + if response_status == status.HTTP_200_OK: + + response_dict = response.json() + assert isinstance(response_dict, dict) + + assert response_dict["failed"] == 0