diff --git a/migrations/env.py b/migrations/env.py index 3eb2da1..fc7a102 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -3,7 +3,7 @@ from alembic import context from sqlalchemy import engine_from_config, pool -from rating_api.models.base import Base +from models.base import BaseDbModel from rating_api.settings import get_settings @@ -21,7 +21,7 @@ # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata -target_metadata = Base.metadata +target_metadata = BaseDbModel.metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/migrations/versions/656228b2d6e0_delete_id_from_comment.py b/migrations/versions/656228b2d6e0_delete_id_from_comment.py new file mode 100644 index 0000000..0eebe9f --- /dev/null +++ b/migrations/versions/656228b2d6e0_delete_id_from_comment.py @@ -0,0 +1,24 @@ +"""delete-id-from-comment + +Revision ID: 656228b2d6e0 +Revises: 7354951f8e4c +Create Date: 2024-10-17 15:30:15.168365 + +""" + +import sqlalchemy as sa +from alembic import op + + +revision = '656228b2d6e0' +down_revision = '7354951f8e4c' +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_column('comment', 'id') + + +def downgrade(): + op.add_column('comment', sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False)) diff --git a/migrations/versions/7354951f8e4c_add_uuid_to_comment.py b/migrations/versions/7354951f8e4c_add_uuid_to_comment.py new file mode 100644 index 0000000..d9ea6a9 --- /dev/null +++ b/migrations/versions/7354951f8e4c_add_uuid_to_comment.py @@ -0,0 +1,26 @@ +"""add-uuid-to-comment + +Revision ID: 7354951f8e4c +Revises: dbe6ca79a40d +Create Date: 2024-10-17 15:25:02.529966 + +""" + +import sqlalchemy as sa +from alembic import op + + +revision = '7354951f8e4c' +down_revision = 'dbe6ca79a40d' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('comment', sa.Column('uuid', sa.UUID(), nullable=True)) + op.execute(f'UPDATE "comment" SET uuid = gen_random_uuid()') + op.alter_column('comment', 'uuid', nullable=False) + + +def downgrade(): + op.drop_column('comment', 'uuid') diff --git a/migrations/versions/dbe6ca79a40d_init.py b/migrations/versions/dbe6ca79a40d_init.py new file mode 100644 index 0000000..3b399c5 --- /dev/null +++ b/migrations/versions/dbe6ca79a40d_init.py @@ -0,0 +1,72 @@ +"""init + +Revision ID: dbe6ca79a40d +Revises: +Create Date: 2024-10-16 23:21:37.960911 + +""" + +import sqlalchemy as sa +from alembic import op + + +revision = 'dbe6ca79a40d' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'lecturer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('first_name', sa.String(), nullable=False), + sa.Column('last_name', sa.String(), nullable=False), + sa.Column('middle_name', sa.String(), nullable=False), + sa.Column('subject', sa.String(), nullable=True), + sa.Column('avatar_link', sa.String(), nullable=True), + sa.Column('timetable_id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('timetable_id'), + ) + op.create_table( + 'comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('create_ts', sa.DateTime(), nullable=False), + sa.Column('update_ts', sa.DateTime(), nullable=False), + sa.Column('subject', sa.String(), nullable=False), + sa.Column('text', sa.String(), nullable=True), + sa.Column('mark_kindness', sa.Integer(), nullable=False), + sa.Column('mark_freebie', sa.Integer(), nullable=False), + sa.Column('mark_clarity', sa.Integer(), nullable=False), + sa.Column('lecturer_id', sa.Integer(), nullable=False), + sa.Column( + 'review_status', + sa.Enum('APPROVED', 'PENDING', 'DISMISSED', name='reviewstatus', native_enum=False), + nullable=False, + ), + sa.ForeignKeyConstraint( + ['lecturer_id'], + ['lecturer.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + op.create_table( + 'lecturer_user_comment', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('lecturer_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('create_ts', sa.DateTime(), nullable=False), + sa.Column('update_ts', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ['lecturer_id'], + ['lecturer.id'], + ), + sa.PrimaryKeyConstraint('id'), + ) + + +def downgrade(): + op.drop_table('lecturer_user_comment') + op.drop_table('comment') + op.drop_table('lecturer') diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..58d469c --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +from .base import Base, BaseDbModel +from .db import * + + +__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"] diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..678078a --- /dev/null +++ b/models/base.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import re + +from sqlalchemy import Integer, not_ +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Mapped, Query, Session, as_declarative, declared_attr, mapped_column + +from rating_api.exceptions import ObjectNotFound + + +@as_declarative() +class Base: + """Base class for all database entities""" + + @declared_attr + def __tablename__(cls) -> str: # pylint: disable=no-self-argument + """Generate database table name automatically. + Convert CamelCase class name to snake_case db table name. + """ + return re.sub(r"(? BaseDbModel: + obj = cls(**kwargs) + session.add(obj) + session.flush() + return obj + + @classmethod + def query(cls, *, with_deleted: bool = False, session: Session) -> Query: + """Get all objects with soft deletes""" + objs = session.query(cls) + if not with_deleted and hasattr(cls, "is_deleted"): + objs = objs.filter(not_(cls.is_deleted)) + return objs + + @classmethod + def get(cls, id: int | str, *, with_deleted=False, session: Session) -> BaseDbModel: + """Get object with soft deletes""" + objs = session.query(cls) + if not with_deleted and hasattr(cls, "is_deleted"): + objs = objs.filter(not_(cls.is_deleted)) + try: + if hasattr(cls, "uuid"): + return objs.filter(cls.uuid == id).one() + return objs.filter(cls.id == id).one() + except NoResultFound: + raise ObjectNotFound(cls, id) + + @classmethod + def update(cls, id: int | str, *, session: Session, **kwargs) -> BaseDbModel: + obj = cls.get(id, session=session) + for k, v in kwargs.items(): + setattr(obj, k, v) + session.flush() + return obj + + @classmethod + def delete(cls, id: int | str, *, session: Session) -> None: + """Soft delete object if possible, else hard delete""" + obj = cls.get(id, session=session) + if hasattr(obj, "is_deleted"): + obj.is_deleted = True + else: + session.delete(obj) + session.flush() diff --git a/models/db.py b/models/db.py new file mode 100644 index 0000000..c39495d --- /dev/null +++ b/models/db.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import datetime +import logging +import uuid +from enum import Enum + +from sqlalchemy import UUID, Boolean, DateTime +from sqlalchemy import Enum as DbEnum +from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from rating_api.settings import get_settings + +from .base import BaseDbModel + + +settings = get_settings() +logger = logging.getLogger(__name__) + + +class ReviewStatus(str, Enum): + APPROVED: str = "approved" + PENDING: str = "pending" + DISMISSED: str = "dismissed" + + +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) + subject: Mapped[str] = mapped_column(String, nullable=True) + avatar_link: Mapped[str] = mapped_column(String, nullable=True) + timetable_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) + comments: Mapped[list[Comment]] = relationship("Comment", back_populates="lecturer") + + +class Comment(BaseDbModel): + uuid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4) + create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) + update_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) + subject: Mapped[str] = mapped_column(String, nullable=False) + text: Mapped[str] = mapped_column(String, nullable=True) + mark_kindness: Mapped[int] = mapped_column(Integer, nullable=False) + mark_freebie: Mapped[int] = mapped_column(Integer, nullable=False) + mark_clarity: Mapped[int] = mapped_column(Integer, nullable=False) + lecturer_id: Mapped[int] = mapped_column(Integer, ForeignKey("lecturer.id")) + lecturer: Mapped[Lecturer] = relationship("Lecturer", back_populates="comments") + review_status: Mapped[ReviewStatus] = mapped_column(DbEnum(ReviewStatus, native_enum=False), nullable=False) + + +class LecturerUserComment(BaseDbModel): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + lecturer_id: Mapped[int] = mapped_column(Integer, ForeignKey("lecturer.id")) + user_id: Mapped[int] = mapped_column(Integer, nullable=False) + create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) + update_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) diff --git a/rating_api/exceptions.py b/rating_api/exceptions.py index e69de29..5b3615e 100644 --- a/rating_api/exceptions.py +++ b/rating_api/exceptions.py @@ -0,0 +1,44 @@ +import datetime +from typing import Type + + +class RatingAPIError(Exception): + eng: str + ru: str + + def __init__(self, eng: str, ru: str) -> None: + self.eng = eng + self.ru = ru + super().__init__(eng) + + +class ObjectNotFound(RatingAPIError): + def __init__(self, obj: type, obj_id_or_name: int | str): + super().__init__( + f"Object {obj.__name__} {obj_id_or_name=} not found", + f"Объект {obj.__name__} с идентификатором {obj_id_or_name} не найден", + ) + + +class AlreadyExists(RatingAPIError): + def __init__(self, obj: type, obj_id_or_name: int | str): + super().__init__( + f"Object {obj.__name__}, {obj_id_or_name=} already exists", + f"Объект {obj.__name__} с идентификатором {obj_id_or_name=} уже существует", + ) + + +class TooManyCommentRequests(RatingAPIError): + delay_time: datetime.timedelta + + def __init__(self, dtime: datetime.timedelta): + self.delay_time = dtime + super().__init__( + f'Too many comment requests. Delay: {dtime}', + f'Слишком много попыток оставить комментарий. Задержка: {dtime}', + ) + + +class ForbiddenAction(RatingAPIError): + def __init__(self, type: Type): + super().__init__(f"Forbidden action with {type.__name__}", f"Запрещенное действие с объектом {type.__name__}") diff --git a/rating_api/models/base.py b/rating_api/models/base.py deleted file mode 100644 index 16065b5..0000000 --- a/rating_api/models/base.py +++ /dev/null @@ -1,22 +0,0 @@ -import re - -from sqlalchemy.ext.declarative import as_declarative, declared_attr - - -@as_declarative() -class Base: - """Base class for all database entities""" - - @classmethod - @declared_attr - def __tablename__(cls) -> str: - """Generate database table name automatically. - Convert CamelCase class name to snake_case db table name. - """ - return re.sub(r"(? str: - attrs = [] - for c in self.__table__.columns: - attrs.append(f"{c.name}={getattr(self, c.name)}") - return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) diff --git a/rating_api/routes/__init__.py b/rating_api/routes/__init__.py index e69de29..5e4fcb1 100644 --- a/rating_api/routes/__init__.py +++ b/rating_api/routes/__init__.py @@ -0,0 +1,5 @@ +from . import exc_handlers +from .base import app + + +__all__ = ["app", "exc_handlers"] diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py index b89a6c3..d320a86 100644 --- a/rating_api/routes/base.py +++ b/rating_api/routes/base.py @@ -3,6 +3,8 @@ from fastapi_sqlalchemy import DBSessionMiddleware from rating_api import __version__ +from rating_api.routes.comment import comment +from rating_api.routes.lecturer import lecturer from rating_api.settings import get_settings @@ -31,3 +33,6 @@ allow_methods=settings.CORS_ALLOW_METHODS, allow_headers=settings.CORS_ALLOW_HEADERS, ) + +app.include_router(lecturer) +app.include_router(comment) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py new file mode 100644 index 0000000..6ff501d --- /dev/null +++ b/rating_api/routes/comment.py @@ -0,0 +1,154 @@ +import datetime +from typing import Annotated, Literal +from uuid import UUID + +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_sqlalchemy import db + +from models import Comment, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, TooManyCommentRequests +from rating_api.schemas.base import StatusResponseModel +from rating_api.schemas.models import ( + CommentGet, + CommentGetAll, + CommentPost, + LecturerGet, + LecturerGetAll, + LecturerPatch, + LecturerPost, + LecturerUserCommentPost, +) +from rating_api.settings import Settings, get_settings + + +settings: Settings = get_settings() +comment = APIRouter(prefix="/comment", tags=["Comment"]) + + +@comment.post("", response_model=CommentGet) +async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depends(UnionAuth())) -> CommentGet: + """ + Создает комментарий к преподавателю в базе данных RatingAPI + Для создания комментария нужно быть авторизованным + """ + lecturer = Lecturer.get(session=db.session, id=lecturer_id) + if not lecturer: + raise ObjectNotFound(Lecturer, lecturer_id) + + user_comments: list[LecturerUserComment] = ( + LecturerUserComment.query(session=db.session).filter(LecturerUserComment.user_id == user.get("id")).all() + ) + for user_comment in user_comments: + if datetime.datetime.utcnow() - user_comment.update_ts < datetime.timedelta( + minutes=settings.COMMENT_CREATE_FREQUENCY_IN_MINUTES + ): + raise TooManyCommentRequests( + dtime=user_comment.update_ts + + datetime.timedelta(minutes=settings.COMMENT_CREATE_FREQUENCY_IN_MINUTES) + - datetime.datetime.utcnow() + ) + + LecturerUserComment.create(session=db.session, lecturer_id=lecturer_id, user_id=user.get('id')) + new_comment = Comment.create( + session=db.session, **comment_info.model_dump(), lecturer_id=lecturer_id, review_status=ReviewStatus.PENDING + ) + return CommentGet.model_validate(new_comment) + + +@comment.get("/{uuid}", response_model=CommentGet) +async def get_comment(uuid: UUID) -> CommentGet: + """ + Возвращает комментарий по его UUID в базе данных RatingAPI + """ + comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == uuid).one_or_none() + if comment is None: + raise ObjectNotFound(Comment, uuid) + return CommentGet.model_validate(comment) + + +@comment.get("", response_model=CommentGetAll) +async def get_comments( + limit: int = 10, + offset: int = 0, + lecturer_id: int | None = None, + order_by: list[Literal["create_ts"]] = Query(default=[]), + unreviewed: bool = False, + user=Depends(UnionAuth(scopes=['rating.comment.review'], auto_error=False, allow_none=True)), +) -> CommentGetAll: + """ + Scopes: `["rating.comment.review"]` + + `limit` - максимальное количество возвращаемых комментариев + + `offset` - смещение, определяющее, с какого по порядку комментария начинать выборку. + Если без смещения возвращается комментарий с условным номером N, + то при значении offset = X будет возвращаться комментарий с номером N + X + + `order_by` - возможное значение `'create_ts'` - возвращается список комментариев отсортированных по времени создания + + `lecturer_id` - вернет все комментарии для преподавателя с конкретным id, по дефолту возвращает вообще все аппрувнутые комментарии. + + `unreviewed` - вернет все непроверенные комментарии, если True. По дефолту False. + """ + comments = Comment.query(session=db.session).all() + if not comments: + raise ObjectNotFound(Comment, 'all') + result = CommentGetAll(limit=limit, offset=offset, total=len(comments)) + for comment in comments[offset : limit + offset]: + result.comments.append(comment) + if lecturer_id: + result.comments = [comment for comment in result.comments if comment.lecturer_id == lecturer_id] + if unreviewed: + if not user: + raise ForbiddenAction(Comment) + if "rating.comment.review" in [scope['name'] for scope in user.get('session_scopes')]: + result.comments = [comment for comment in result.comments if comment.review_status is ReviewStatus.PENDING] + else: + raise ForbiddenAction(Comment) + else: + result.comments = [comment for comment in result.comments if comment.review_status is ReviewStatus.APPROVED] + if "create_ts" in order_by: + result.comments.sort(key=lambda comment: comment.create_ts) + result.total = len(result.comments) + result.comments = [CommentGet.model_validate(comment) for comment in result.comments] + return result + + +@comment.patch("/{uuid}", response_model=CommentGet) +async def review_comment( + uuid: UUID, + review_status: Literal[ReviewStatus.APPROVED, ReviewStatus.DISMISSED] = ReviewStatus.DISMISSED, + _=Depends(UnionAuth(scopes=["rating.comment.review"], allow_none=False, auto_error=True)), +) -> CommentGet: + """ + Scopes: `["rating.comment.review"]` + Проверка комментария и присваивания ему статуса по его UUID в базе данных RatingAPI + + `review_status` - возможные значения + `approved` - комментарий одобрен и возвращается при запросе лектора + `dismissed` - комментарий отклонен, не отображается в запросе лектора + """ + check_comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == uuid).one_or_none() + if not check_comment: + raise ObjectNotFound(Comment, uuid) + return CommentGet.model_validate(Comment.update(session=db.session, id=uuid, review_status=review_status)) + + +@comment.delete("/{uuid}", response_model=StatusResponseModel) +async def delete_comment( + uuid: UUID, _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) +): + """ + Scopes: `["rating.comment.delete"]` + + Удаляет комментарий по его UUID в базе данных RatingAPI + """ + check_comment = Comment.get(session=db.session, id=uuid) + if check_comment is None: + raise ObjectNotFound(Comment, uuid) + Comment.delete(session=db.session, id=uuid) + + return StatusResponseModel( + status="Success", message="Comment has been deleted", ru="Комментарий удален из RatingAPI" + ) diff --git a/rating_api/routes/exc_handlers.py b/rating_api/routes/exc_handlers.py new file mode 100644 index 0000000..25df7b3 --- /dev/null +++ b/rating_api/routes/exc_handlers.py @@ -0,0 +1,35 @@ +import starlette.requests +from starlette.responses import JSONResponse + +from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, TooManyCommentRequests +from rating_api.schemas.base import StatusResponseModel + +from .base import app + + +@app.exception_handler(ObjectNotFound) +async def not_found_handler(req: starlette.requests.Request, exc: ObjectNotFound): + return JSONResponse( + content=StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), status_code=404 + ) + + +@app.exception_handler(AlreadyExists) +async def already_exists_handler(req: starlette.requests.Request, exc: AlreadyExists): + return JSONResponse( + content=StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), status_code=409 + ) + + +@app.exception_handler(TooManyCommentRequests) +async def too_many_comment_handler(req: starlette.requests.Request, exc: AlreadyExists): + return JSONResponse( + content=StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), status_code=429 + ) + + +@app.exception_handler(ForbiddenAction) +async def forbidden_action_handler(req: starlette.requests.Request, exc: AlreadyExists): + return JSONResponse( + content=StatusResponseModel(status="Error", message=exc.eng, ru=exc.ru).model_dump(), status_code=403 + ) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py new file mode 100644 index 0000000..92cbaf6 --- /dev/null +++ b/rating_api/routes/lecturer.py @@ -0,0 +1,194 @@ +from typing import Literal + +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_sqlalchemy import db +from sqlalchemy import and_ + +from models import Comment, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.exceptions import AlreadyExists, ObjectNotFound +from rating_api.schemas.base import StatusResponseModel +from rating_api.schemas.models import CommentGet, LecturerGet, LecturerGetAll, LecturerPatch, LecturerPost + + +lecturer = APIRouter(prefix="/lecturer", tags=["Lecturer"]) + + +@lecturer.post("", response_model=LecturerGet) +async def create_lecturer( + lecturer_info: LecturerPost, + _=Depends(UnionAuth(scopes=["rating.lecturer.create"], allow_none=False, auto_error=True)), +) -> LecturerGet: + """ + Scopes: `["rating.lecturer.create"]` + + Создает преподавателя в базе данных RatingAPI + """ + get_lecturer: Lecturer = ( + Lecturer.query(session=db.session).filter(Lecturer.timetable_id == lecturer_info.timetable_id).one_or_none() + ) + if get_lecturer is None: + new_lecturer: Lecturer = Lecturer.create(session=db.session, **lecturer_info.model_dump()) + return LecturerGet.model_validate(new_lecturer) + raise AlreadyExists(Lecturer, lecturer_info.timetable_id) + + +@lecturer.get("/{id}", response_model=LecturerGet) +async def get_lecturer( + id: int, + info: list[Literal["comments", "mark"]] = Query(default=[]), + _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)), +) -> LecturerGet: + """ + Scopes: `["rating.lecturer.read"]` + + Возвращает преподавателя по его ID в базе данных RatingAPI + + *QUERY* `info: string` - возможные значения `'comments'`, `'mark'`. + Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. + Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. + + Subject лектора возвращшается либо из базы данных, либо из любого аппрувнутого комментария + """ + lecturer: Lecturer = Lecturer.query(session=db.session).filter(Lecturer.id == id).one_or_none() + if lecturer is None: + raise ObjectNotFound(Lecturer, id) + result = LecturerGet.model_validate(lecturer) + result.comments = None + if lecturer.comments: + approved_comments: list[CommentGet] = [ + CommentGet.model_validate(comment) + for comment in lecturer.comments + if comment.review_status is ReviewStatus.APPROVED + ] + if "comments" in info and approved_comments: + result.comments = approved_comments + 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) + general_marks = [result.mark_freebie, result.mark_kindness, result.mark_clarity] + result.mark_general = sum(general_marks) / len(general_marks) + if not result.subject and approved_comments: + result.subject = approved_comments[-1].subject + 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: list[Literal["general", '']] = Query(default=[]), + subject: str = Query(''), + _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)), +) -> LecturerGetAll: + """ + Scopes: `["rating.lecturer.read"]` + + `limit` - максимальное количество возвращаемых преподавателей + + `offset` - нижняя граница получения преподавателей, т.е. если по дефолту первым возвращается преподаватель с условным номером N, то при наличии ненулевого offset будет возвращаться преподаватель с номером N + offset + + `order_by` - возможные значения `'general'`. + Если передано `'general'` - возвращается список преподавателей отсортированных по общей оценке + + `info` - возможные значения `'comments'`, `'mark'`. + Если передано `'comments'`, то возвращаются одобренные комментарии к преподавателю. + Если передано `'mark'`, то возвращаются общие средние оценки, а также суммарная средняя оценка по всем одобренным комментариям. + + `subject` + Если передано `subject` - возвращает всех преподавателей, для которых переданное значение совпадает с их предметом преподавания. + Также возвращает всех преподавателей, у которых есть комментарий с совпадающим с данным subject. + """ + lecturers = Lecturer.query(session=db.session).all() + if not lecturers: + raise ObjectNotFound(Lecturer, 'all') + result = LecturerGetAll(limit=limit, offset=offset, total=len(lecturers)) + for db_lecturer in lecturers[offset : limit + offset]: + 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 "comments" in info and approved_comments: + lecturer_to_result.comments = approved_comments + 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 + ) + general_marks = [ + lecturer_to_result.mark_freebie, + lecturer_to_result.mark_kindness, + lecturer_to_result.mark_clarity, + ] + lecturer_to_result.mark_general = sum(general_marks) / len(general_marks) + if not lecturer_to_result.subject and approved_comments: + lecturer_to_result.subject = approved_comments[-1].subject + result.lecturers.append(lecturer_to_result) + if "general" in order_by: + result.lecturers.sort(key=lambda item: (item.mark_general is None, item.mark_general)) + if subject: + result.lecturers = [lecturer for lecturer in result.lecturers if lecturer.subject == subject] + result.total = len(result.lecturers) + return result + + +@lecturer.patch("/{id}", response_model=LecturerGet) +async def update_lecturer( + id: int, + lecturer_info: LecturerPatch, + _=Depends(UnionAuth(scopes=["rating.lecturer.update"], allow_none=False, auto_error=True)), +) -> LecturerGet: + """ + Scopes: `["rating.lecturer.update"]` + """ + lecturer = Lecturer.get(id, session=db.session) + if lecturer is None: + raise ObjectNotFound(Lecturer, id) + + check_timetable_id = ( + Lecturer.query(session=db.session) + .filter(and_(Lecturer.timetable_id == lecturer_info.timetable_id, Lecturer.id != id)) + .one_or_none() + ) + if check_timetable_id: + raise AlreadyExists(Lecturer.timetable_id, lecturer_info.timetable_id) + + result = LecturerGet.model_validate( + Lecturer.update(lecturer.id, **lecturer_info.model_dump(exclude_unset=True), session=db.session) + ) + result.comments = None + return result + + +@lecturer.delete("/{id}", response_model=StatusResponseModel) +async def delete_lecturer( + id: int, _=Depends(UnionAuth(scopes=["rating.lecturer.delete"], allow_none=False, auto_error=True)) +): + """ + Scopes: `["rating.lecturer.delete"]` + """ + check_lecturer = Lecturer.get(session=db.session, id=id) + if check_lecturer is None: + raise ObjectNotFound(Lecturer, id) + for comment in check_lecturer.comments: + Comment.delete(id=comment.uuid, session=db.session) + + lecturer_user_comments = LecturerUserComment.query(session=db.session).filter(LecturerUserComment.lecturer_id == id) + for lecturer_user_comment in lecturer_user_comments: + LecturerUserComment.delete(lecturer_user_comment.id, session=db.session) + + Lecturer.delete(session=db.session, id=id) + return StatusResponseModel( + status="Success", message="Lecturer has been deleted", ru="Преподаватель удален из RatingAPI" + ) diff --git a/rating_api/routes/models/__init__.py b/rating_api/routes/models/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/rating_api/models/__init__.py b/rating_api/schemas/__init__.py similarity index 100% rename from rating_api/models/__init__.py rename to rating_api/schemas/__init__.py diff --git a/rating_api/routes/models/base.py b/rating_api/schemas/base.py similarity index 69% rename from rating_api/routes/models/base.py rename to rating_api/schemas/base.py index 7748216..fdb58a7 100644 --- a/rating_api/routes/models/base.py +++ b/rating_api/schemas/base.py @@ -8,4 +8,10 @@ def __repr__(self) -> str: attrs.append(f"{k}={v}") return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) - model_config = ConfigDict(from_attributes=True, extra="ignore") + model_config = ConfigDict(from_attributes=True) + + +class StatusResponseModel(Base): + status: str + message: str + ru: str diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py new file mode 100644 index 0000000..90b8f0b --- /dev/null +++ b/rating_api/schemas/models.py @@ -0,0 +1,76 @@ +import datetime +from uuid import UUID + +from rating_api.schemas.base import Base + + +class CommentGet(Base): + uuid: UUID + create_ts: datetime.datetime + update_ts: datetime.datetime + subject: str + text: str + mark_kindness: int + mark_freebie: int + mark_clarity: int + lecturer_id: int + + +class CommentPost(Base): + subject: str + text: str + mark_kindness: int + mark_freebie: int + mark_clarity: int + + +class CommentGetAll(Base): + comments: list[CommentGet] = [] + limit: int + offset: int + total: int + + +class LecturerUserCommentPost(Base): + lecturer_id: int + user_id: int + + +class LecturerGet(Base): + id: int + first_name: str + last_name: str + middle_name: str + avatar_link: str | None = None + subject: 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 + comments: list[CommentGet] | None = None + + +class LecturerGetAll(Base): + lecturers: list[LecturerGet] = [] + limit: int + offset: int + total: int + + +class LecturerPost(Base): + first_name: str + last_name: str + middle_name: str + subject: str | None = None + avatar_link: str | None = None + timetable_id: int + + +class LecturerPatch(Base): + first_name: str | None = None + last_name: str | None = None + middle_name: str | None = None + subject: str | None = None + avatar_link: str | None = None + timetable_id: int | None = None diff --git a/rating_api/settings.py b/rating_api/settings.py index da8fa70..ed09146 100644 --- a/rating_api/settings.py +++ b/rating_api/settings.py @@ -10,7 +10,7 @@ class Settings(BaseSettings): DB_DSN: PostgresDsn = 'postgresql://postgres@localhost:5432/postgres' ROOT_PATH: str = '/' + os.getenv("APP_NAME", "") - + COMMENT_CREATE_FREQUENCY_IN_MINUTES: int = 1 CORS_ALLOW_ORIGINS: list[str] = ['*'] CORS_ALLOW_CREDENTIALS: bool = True CORS_ALLOW_METHODS: list[str] = ['*'] @@ -21,5 +21,4 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: - settings = Settings() - return settings + return Settings() diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..cc467ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,30 @@ +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from rating_api.routes import app +from rating_api.settings import Settings + + +@pytest.fixture +def client(mocker): + user_mock = mocker.patch('auth_lib.fastapi.UnionAuth.__call__') + user_mock.return_value = { + "session_scopes": [{"id": 0, "name": "string", "comment": "string"}], + "user_scopes": [{"id": 0, "name": "string", "comment": "string"}], + "indirect_groups": [{"id": 0, "name": "string", "parent_id": 0}], + "groups": [{"id": 0, "name": "string", "parent_id": 0}], + "id": 0, + "email": "string", + } + client = TestClient(app) + return client + + +@pytest.fixture +def dbsession() -> Session: + settings = Settings() + engine = create_engine(str(settings.DB_DSN), pool_pre_ping=True) + TestingSessionLocal = sessionmaker(bind=engine) + yield TestingSessionLocal() diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py new file mode 100644 index 0000000..88b8d56 --- /dev/null +++ b/tests/test_routes/test_comment.py @@ -0,0 +1,116 @@ +import logging +import uuid +from datetime import datetime, timedelta + +import httpx as httpx +from sqlalchemy.orm import Session +from starlette import status +from starlette.testclient import TestClient + +from models import Comment, Lecturer, LecturerUserComment, ReviewStatus +from rating_api.schemas.models import LecturerPost +from rating_api.settings import get_settings + + +logger = logging.getLogger(__name__) +url: str = '/comment' + +settings = get_settings() + + +def test_create_comment(client, dbsession): + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} + lecturer: Lecturer = Lecturer(**body) + dbsession.add(lecturer) + dbsession.commit() + + body = { + "subject": "Физика", + "text": "Хороший препод", + "mark_kindness": 1, + "mark_freebie": -2, + "mark_clarity": 0, + } + params = {"lecturer_id": lecturer.id} + post_response = client.post(url, json=body, params=params) + print(post_response.json()) + assert post_response.status_code == status.HTTP_200_OK + json_response = post_response.json() + comment = Comment.query(session=dbsession).filter(Comment.uuid == json_response["uuid"]).one_or_none() + assert comment is not None + user_comment = ( + LecturerUserComment.query(session=dbsession) + .filter(LecturerUserComment.lecturer_id == lecturer.id) + .one_or_none() + ) + assert user_comment is not None + dbsession.delete(user_comment) + dbsession.delete(comment) + dbsession.delete(lecturer) + dbsession.commit() + post_response = client.post(url, json=body, params=params) + assert post_response.status_code == status.HTTP_404_NOT_FOUND + + +def test_get_comment(client, dbsession): + body = { + "first_name": 'Иван', + "last_name": 'Иванов', + "middle_name": 'Иванович', + "timetable_id": 0, + } + lecturer: Lecturer = Lecturer(**body) + dbsession.add(lecturer) + dbsession.commit() + + body = { + "lecturer_id": lecturer.id, + "subject": "Физика", + "text": "Хороший препод", + "mark_kindness": 1, + "mark_freebie": -2, + "mark_clarity": 0, + "review_status": ReviewStatus.APPROVED, + } + comment: Comment = Comment(**body) + dbsession.add(comment) + dbsession.commit() + response_comment = client.get(f'{url}/{comment.uuid}') + assert response_comment.status_code == status.HTTP_200_OK + random_uuid = uuid.uuid4() + response = client.get(f'{url}/{random_uuid}') + assert response.status_code == status.HTTP_404_NOT_FOUND + comment = Comment.query(session=dbsession).filter(Comment.uuid == response_comment.json()["uuid"]).one_or_none() + assert comment is not None + dbsession.delete(comment) + dbsession.delete(lecturer) + dbsession.commit() + + +def test_delete_comment(client, dbsession): + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} + lecturer: Lecturer = Lecturer(**body) + dbsession.add(lecturer) + dbsession.commit() + + body = { + "lecturer_id": lecturer.id, + "subject": "Физика", + "text": "Хороший препод", + "mark_kindness": 1, + "mark_freebie": -2, + "mark_clarity": 0, + "review_status": ReviewStatus.APPROVED, + } + comment: Comment = Comment(**body) + dbsession.add(comment) + dbsession.commit() + response = client.delete(f'{url}/{comment.uuid}') + assert response.status_code == status.HTTP_200_OK + random_uuid = uuid.uuid4() + response = client.delete(f'{url}/{random_uuid}') + assert response.status_code == status.HTTP_404_NOT_FOUND + comment = Comment.query(session=dbsession).filter(Comment.uuid == comment.uuid).one_or_none() + assert comment is None + dbsession.delete(lecturer) + dbsession.commit() diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py new file mode 100644 index 0000000..d4b4fc3 --- /dev/null +++ b/tests/test_routes/test_lecturer.py @@ -0,0 +1,144 @@ +import logging +from datetime import datetime, timedelta + +import httpx as httpx +from sqlalchemy.orm import Session +from starlette import status +from starlette.testclient import TestClient + +from models import Comment, Lecturer, ReviewStatus +from rating_api.schemas.models import LecturerPost +from rating_api.settings import get_settings + + +logger = logging.getLogger(__name__) +url: str = '/lecturer' + +settings = get_settings() + + +def test_create_lecturer(client, dbsession): + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} + post_response = client.post(url, json=body) + assert post_response.status_code == status.HTTP_200_OK + check_same_response = client.post(url, json=body) + assert check_same_response.status_code == status.HTTP_409_CONFLICT + lecturer = dbsession.query(Lecturer).filter(Lecturer.timetable_id == 0).one_or_none() + assert lecturer is not None + dbsession.delete(lecturer) + dbsession.commit() + lecturer = dbsession.query(Lecturer).filter(Lecturer.timetable_id == 0).one_or_none() + assert lecturer is None + + +def test_get_lecturer(client, dbsession): + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} + lecturer: Lecturer = Lecturer(**body) + dbsession.add(lecturer) + dbsession.commit() + db_lecturer: Lecturer = Lecturer.query(session=dbsession).filter(Lecturer.timetable_id == 0).one_or_none() + assert db_lecturer is not None + get_response = client.get(f'{url}/{db_lecturer.id}') + print(get_response.json()) + assert get_response.status_code == 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["comments"] is None + dbsession.delete(lecturer) + dbsession.commit() + + +def test_get_lecturer_with_comments(client, dbsession): + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} + lecturer: Lecturer = Lecturer(**body) + dbsession.add(lecturer) + dbsession.commit() + db_lecturer: Lecturer = Lecturer.query(session=dbsession).filter(Lecturer.timetable_id == 0).one_or_none() + assert db_lecturer is not None + + comment1: dict = { + "subject": "Физика", + "text": "Хороший преподаватель", + "mark_kindness": 2, + "mark_freebie": 0, + "mark_clarity": 2, + "lecturer_id": db_lecturer.id, + "review_status": ReviewStatus.APPROVED, + } + comment2: dict = { + "subject": "Физика", + "text": "Средне", + "mark_kindness": -1, + "mark_freebie": 1, + "mark_clarity": -1, + "lecturer_id": db_lecturer.id, + "review_status": ReviewStatus.APPROVED, + } + comment3: dict = { + "subject": "Физика", + "text": "Средне", + "mark_kindness": 2, + "mark_freebie": 2, + "mark_clarity": 2, + "lecturer_id": db_lecturer.id, + "review_status": ReviewStatus.PENDING, + } + comment1: Comment = Comment.create(session=dbsession, **comment1) + comment2: Comment = Comment.create(session=dbsession, **comment2) + comment3: Comment = Comment.create(session=dbsession, **comment3) + dbsession.commit() + assert comment1 is not None + assert comment2 is not None + assert comment3 is not None + query = { + "info": ['comments', 'mark'], + } + response = client.get(f'{url}/{db_lecturer.id}', params=query) + print(response.json()) + assert response.status_code == status.HTTP_200_OK + json_response = response.json() + assert json_response["mark_kindness"] == 0.5 + assert json_response["mark_freebie"] == 0.5 + assert json_response["mark_clarity"] == 0.5 + assert json_response["mark_general"] == 0.5 + assert json_response["subject"] == "Физика" + assert len(json_response["comments"]) != 0 + + +def test_update_lecturer(client, dbsession): + body = { + "first_name": 'Алексей', + "last_name": 'Алексеев', + "middle_name": 'Алексеевич', + } + db_lecturer: Lecturer = Lecturer.query(session=dbsession).filter(Lecturer.timetable_id == 0).one_or_none() + assert db_lecturer is not None + response = client.patch(f"{url}/{db_lecturer.id}", json=body) + assert response.status_code == status.HTTP_200_OK + json_response = response.json() + assert json_response["first_name"] == 'Алексей' + assert json_response["last_name"] == 'Алексеев' + assert json_response["middle_name"] == "Алексеевич" + body = { + "first_name": 'Иван', + "last_name": 'Иванов', + "middle_name": 'Иванович', + } + response = client.patch(f"{url}/{db_lecturer.id}", json=body) + assert response.status_code == status.HTTP_200_OK + json_response = response.json() + assert json_response["first_name"] == 'Иван' + assert json_response["last_name"] == 'Иванов' + assert json_response["middle_name"] == "Иванович" + + +def test_delete_lecturer(client, dbsession): + lecturer = dbsession.query(Lecturer).filter(Lecturer.timetable_id == 0).one_or_none() + assert lecturer is not None + response = client.delete(f"{url}/{lecturer.id}") + assert response.status_code == status.HTTP_200_OK + response = client.delete(f"{url}/{lecturer.id}") + assert response.status_code == status.HTTP_404_NOT_FOUND