From 3f736c6d7ca84aab334e8227f459c9c0ad3a69fe Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 01:34:13 +0300 Subject: [PATCH 01/12] DB. Lecturer routes --- Makefile | 2 +- migrations/env.py | 5 +- models/__init__.py | 3 + models/base.py | 74 ++++++++ models/db.py | 48 +++++ rating_api/exceptions.py | 24 +++ rating_api/models/base.py | 22 --- rating_api/routes/__init__.py | 5 + rating_api/routes/base.py | 4 +- rating_api/routes/exc_handlers.py | 23 +++ rating_api/routes/lecturer.py | 171 ++++++++++++++++++ rating_api/routes/models/__init__.py | 0 rating_api/{models => schemas}/__init__.py | 0 rating_api/{routes/models => schemas}/base.py | 9 +- rating_api/schemas/models.py | 50 +++++ rating_api/settings.py | 3 +- 16 files changed, 412 insertions(+), 31 deletions(-) create mode 100644 models/__init__.py create mode 100644 models/base.py create mode 100644 models/db.py delete mode 100644 rating_api/models/base.py create mode 100644 rating_api/routes/exc_handlers.py create mode 100644 rating_api/routes/lecturer.py delete mode 100644 rating_api/routes/models/__init__.py rename rating_api/{models => schemas}/__init__.py (100%) rename rating_api/{routes/models => schemas}/base.py (69%) create mode 100644 rating_api/schemas/models.py diff --git a/Makefile b/Makefile index c6a2d1c..fcb735f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ run: - source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app + uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app& configure: venv source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt diff --git a/migrations/env.py b/migrations/env.py index 3eb2da1..a4d5c2f 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -3,9 +3,8 @@ from alembic import context from sqlalchemy import engine_from_config, pool -from rating_api.models.base import Base from rating_api.settings import get_settings - +from models import * # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -21,7 +20,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/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..46d0013 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from .base import Base, BaseDbModel +from .db import * +__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"] \ No newline at end of file diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..4979b17 --- /dev/null +++ b/models/base.py @@ -0,0 +1,74 @@ +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, *, 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: + return objs.filter(cls.id == id).one() + except NoResultFound: + raise ObjectNotFound(cls, id) + + @classmethod + def update(cls, id: int, *, 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, *, 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..6c93ed8 --- /dev/null +++ b/models/db.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import datetime +import logging +from enum import Enum +from rating_api.settings import get_settings +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .base import BaseDbModel +from sqlalchemy import Enum as DbEnum + +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): + id: Mapped[int] = mapped_column(Integer, primary_key=True) + 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..18d6b93 100644 --- a/rating_api/exceptions.py +++ b/rating_api/exceptions.py @@ -0,0 +1,24 @@ +import datetime + + +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=} уже существует", + ) \ No newline at end of file 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..e306335 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"] \ No newline at end of file diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py index b89a6c3..8cb0778 100644 --- a/rating_api/routes/base.py +++ b/rating_api/routes/base.py @@ -4,7 +4,7 @@ from rating_api import __version__ from rating_api.settings import get_settings - +from rating_api.routes.lecturer import lecturer settings = get_settings() app = FastAPI( @@ -31,3 +31,5 @@ allow_methods=settings.CORS_ALLOW_METHODS, allow_headers=settings.CORS_ALLOW_HEADERS, ) + +app.include_router(lecturer) \ No newline at end of file diff --git a/rating_api/routes/exc_handlers.py b/rating_api/routes/exc_handlers.py new file mode 100644 index 0000000..f7f71de --- /dev/null +++ b/rating_api/routes/exc_handlers.py @@ -0,0 +1,23 @@ +import starlette.requests +from starlette.responses import JSONResponse + +from rating_api.schemas.base import StatusResponseModel +from rating_api.exceptions import ( + AlreadyExists, + ObjectNotFound, +) + +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 + ) \ No newline at end of file diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py new file mode 100644 index 0000000..b75137c --- /dev/null +++ b/rating_api/routes/lecturer.py @@ -0,0 +1,171 @@ +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_sqlalchemy import db +from sqlalchemy import func, and_ +from models import Lecturer, ReviewStatus +# from auth_backend.base import StatusResponseModel +# from auth_backend.models.db import Scope, UserSession +# from auth_backend.schemas.models import ScopeGet, ScopePatch, ScopePost +from auth_lib.fastapi import UnionAuth + +from rating_api.exceptions import AlreadyExists, ObjectNotFound +from rating_api.schemas.base import StatusResponseModel +from rating_api.schemas.models import LecturerGet, LecturerPost, Comment, LecturerGetAll, LecturerPatch + +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.dict()) + 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[Comment] = [Comment.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[Comment] = [Comment.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] + return result + + + + + +@lecturer.patch("/{id}", response_model=LecturerGet) +async def update_lecturer( + id: int, + lecturer_info: LecturerPatch, + _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)) +) -> LecturerGet: + """ + Scopes: `["auth.scope.update"]` + """ + lecturer = Lecturer.get(id, session=db.session) + 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"]` + """ + Lecturer.delete(session=db.session, id=id) + return StatusResponseModel(status="Success", message="Lecturer has been deleted", ru="Преподаватель удален из RatingAPI") \ No newline at end of file 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..ca49de4 100644 --- a/rating_api/routes/models/base.py +++ b/rating_api/schemas/base.py @@ -1,6 +1,5 @@ from pydantic import BaseModel, ConfigDict - class Base(BaseModel): def __repr__(self) -> str: attrs = [] @@ -8,4 +7,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 \ No newline at end of file diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py new file mode 100644 index 0000000..a7aa972 --- /dev/null +++ b/rating_api/schemas/models.py @@ -0,0 +1,50 @@ +import datetime + +from rating_api.schemas.base import Base + +class Comment(Base): + id: int + 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 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[Comment] | 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..b2a1ceb 100644 --- a/rating_api/settings.py +++ b/rating_api/settings.py @@ -21,5 +21,4 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: - settings = Settings() - return settings + return Settings() From aa14c584c4da63bd1aa1d43d4a969074be273c81 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 03:35:48 +0300 Subject: [PATCH 02/12] Comment. Linting --- .github/workflows/checks.yml | 2 +- Makefile | 34 ++--- migrations/env.py | 3 +- migrations/versions/dbe6ca79a40d_init.py | 76 +++++++++++ models/__init__.py | 4 +- models/base.py | 2 + models/db.py | 15 ++- rating_api/exceptions.py | 22 +++- rating_api/routes/__init__.py | 2 +- rating_api/routes/base.py | 7 +- rating_api/routes/comment.py | 161 +++++++++++++++++++++++ rating_api/routes/exc_handlers.py | 22 +++- rating_api/routes/lecturer.py | 84 ++++++------ rating_api/schemas/base.py | 3 +- rating_api/schemas/models.py | 29 +++- rating_api/settings.py | 2 +- 16 files changed, 395 insertions(+), 73 deletions(-) create mode 100644 migrations/versions/dbe6ca79a40d_init.py create mode 100644 rating_api/routes/comment.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index f520c68..62c9320 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -58,7 +58,7 @@ jobs: with: requirementsFiles: "requirements.txt requirements.dev.txt" - uses: psf/black@stable - - name: Comment if linting failed + - name: CommentGet if linting failed if: ${{ failure() }} uses: thollander/actions-comment-pull-request@v2 with: diff --git a/Makefile b/Makefile index fcb735f..3f85a5c 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,19 @@ run: - uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app& + uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app -configure: venv - source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt - -venv: - python3.11 -m venv venv - -format: - autoflake -r --in-place --remove-all-unused-imports ./rating_api - isort ./rating_api - black ./rating_api - -db: - docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 - -migrate: - alembic upgrade head +#configure: venv +# source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt +# +#venv: +# python3.11 -m venv venv +# +#format: +# autoflake -r --in-place --remove-all-unused-imports ./rating_api +# isort ./rating_api +# black ./rating_api +# +#db: +# docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 +# +#migrate: +# alembic upgrade head diff --git a/migrations/env.py b/migrations/env.py index a4d5c2f..16c5051 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -3,8 +3,9 @@ from alembic import context from sqlalchemy import engine_from_config, pool -from rating_api.settings import get_settings from models import * +from rating_api.settings import get_settings + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/migrations/versions/dbe6ca79a40d_init.py b/migrations/versions/dbe6ca79a40d_init.py new file mode 100644 index 0000000..45874df --- /dev/null +++ b/migrations/versions/dbe6ca79a40d_init.py @@ -0,0 +1,76 @@ +"""init + +Revision ID: dbe6ca79a40d +Revises: +Create Date: 2024-10-16 23:21:37.960911 + +""" +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = 'dbe6ca79a40d' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + 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'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('lecturer_user_comment') + op.drop_table('comment') + op.drop_table('lecturer') + # ### end Alembic commands ### diff --git a/models/__init__.py b/models/__init__.py index 46d0013..58d469c 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,3 +1,5 @@ from .base import Base, BaseDbModel from .db import * -__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"] \ No newline at end of file + + +__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"] diff --git a/models/base.py b/models/base.py index 4979b17..d85a9d5 100644 --- a/models/base.py +++ b/models/base.py @@ -1,9 +1,11 @@ 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 diff --git a/models/db.py b/models/db.py index 6c93ed8..e40ddb4 100644 --- a/models/db.py +++ b/models/db.py @@ -3,20 +3,27 @@ import datetime import logging from enum import Enum -from rating_api.settings import get_settings -from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String + +from sqlalchemy import 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 -from sqlalchemy import Enum as DbEnum + 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) @@ -27,6 +34,7 @@ class Lecturer(BaseDbModel): timetable_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False) comments: Mapped[list[Comment]] = relationship("Comment", back_populates="lecturer") + class Comment(BaseDbModel): id: Mapped[int] = mapped_column(Integer, primary_key=True) create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow, nullable=False) @@ -40,6 +48,7 @@ class Comment(BaseDbModel): 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")) diff --git a/rating_api/exceptions.py b/rating_api/exceptions.py index 18d6b93..5b3615e 100644 --- a/rating_api/exceptions.py +++ b/rating_api/exceptions.py @@ -1,4 +1,5 @@ import datetime +from typing import Type class RatingAPIError(Exception): @@ -10,15 +11,34 @@ def __init__(self, eng: str, ru: str) -> None: 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=} уже существует", - ) \ No newline at end of file + ) + + +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/routes/__init__.py b/rating_api/routes/__init__.py index e306335..5e4fcb1 100644 --- a/rating_api/routes/__init__.py +++ b/rating_api/routes/__init__.py @@ -2,4 +2,4 @@ from .base import app -__all__ = ["app", "exc_handlers"] \ No newline at end of file +__all__ = ["app", "exc_handlers"] diff --git a/rating_api/routes/base.py b/rating_api/routes/base.py index 8cb0778..d320a86 100644 --- a/rating_api/routes/base.py +++ b/rating_api/routes/base.py @@ -3,8 +3,10 @@ from fastapi_sqlalchemy import DBSessionMiddleware from rating_api import __version__ -from rating_api.settings import get_settings +from rating_api.routes.comment import comment from rating_api.routes.lecturer import lecturer +from rating_api.settings import get_settings + settings = get_settings() app = FastAPI( @@ -32,4 +34,5 @@ allow_headers=settings.CORS_ALLOW_HEADERS, ) -app.include_router(lecturer) \ No newline at end of file +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..c5e38d3 --- /dev/null +++ b/rating_api/routes/comment.py @@ -0,0 +1,161 @@ +import datetime +from typing import Annotated, Literal + +# from auth_backend.base import StatusResponseModel +# from auth_backend.models.db import Scope, UserSession +# from auth_backend.schemas.models import ScopeGet, ScopePatch, ScopePost +from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_sqlalchemy import db +from sqlalchemy import and_, func + +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, + **LecturerUserCommentPost( + **comment_info.dict(exclude_unset=True), lecturer_id=lecturer_id, user_id=user.get('id') + ).dict(), + ) + new_comment = Comment.create( + session=db.session, **comment_info.dict(), lecturer_id=lecturer_id, review_status=ReviewStatus.PENDING + ) + return CommentGet.model_validate(new_comment) + + +@comment.get("/{id}", response_model=CommentGet) +async def get_comment(id: int) -> CommentGet: + """ + Возвращает комментарий по его ID в базе данных RatingAPI + """ + comment: Comment = Comment.query(session=db.session).filter(Comment.id == id).one_or_none() + if comment is None: + raise ObjectNotFound(Comment, id) + 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 будет возвращаться комментарий с номером N + offset + + `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("/{id}", response_model=CommentGet) +async def review_comment( + id: int, + 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"]` + Проверка комментария и присваивания ему статуса по его ID в базе данных RatingAPI + + `review_status` - возможные значения + `approved` - комментарий одобрен и возвращается при запросе лектора + `dismissed` - комментарий отклонен, не отображается в запросе лектора + """ + check_comment: Comment = Comment.query(session=db.session).filter(Comment.id == id).one_or_none() + if not check_comment: + raise ObjectNotFound(Comment, id) + return CommentGet.model_validate(Comment.update(session=db.session, id=id, review_status=review_status)) + + +@comment.delete("/{id}", response_model=StatusResponseModel) +async def delete_comment( + id: int, + # _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) +): + """ + Scopes: `["rating.comment.delete"]` + + Удаляет комментарий по его ID в базе данных RatingAPI + """ + check_comment = Comment.get(session=db.session, id=id) + if check_comment is None: + raise ObjectNotFound(Comment, id) + Comment.delete(session=db.session, id=id) + + 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 index f7f71de..25df7b3 100644 --- a/rating_api/routes/exc_handlers.py +++ b/rating_api/routes/exc_handlers.py @@ -1,11 +1,8 @@ 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 rating_api.exceptions import ( - AlreadyExists, - ObjectNotFound, -) from .base import app @@ -16,8 +13,23 @@ async def not_found_handler(req: starlette.requests.Request, exc: ObjectNotFound 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 - ) \ No newline at end of file + ) + + +@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 index b75137c..9056249 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -1,17 +1,18 @@ from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi_sqlalchemy import db -from sqlalchemy import func, and_ -from models import Lecturer, ReviewStatus # from auth_backend.base import StatusResponseModel # from auth_backend.models.db import Scope, UserSession # from auth_backend.schemas.models import ScopeGet, ScopePatch, ScopePost from auth_lib.fastapi import UnionAuth +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi_sqlalchemy import db +from sqlalchemy import and_, func +from models import Lecturer, ReviewStatus from rating_api.exceptions import AlreadyExists, ObjectNotFound from rating_api.schemas.base import StatusResponseModel -from rating_api.schemas.models import LecturerGet, LecturerPost, Comment, LecturerGetAll, LecturerPatch +from rating_api.schemas.models import CommentGet, LecturerGet, LecturerGetAll, LecturerPatch, LecturerPost + lecturer = APIRouter(prefix="/lecturer", tags=["Lecturer"]) @@ -26,22 +27,20 @@ async def create_lecturer( Создает преподавателя в базе данных RatingAPI """ - get_lecturer: Lecturer = Lecturer.query(session=db.session).filter(Lecturer.timetable_id == lecturer_info.timetable_id).one_or_none() + 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.dict()) 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)), + 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"]` @@ -60,8 +59,11 @@ async def get_lecturer( result = LecturerGet.model_validate(lecturer) result.comments = None if lecturer.comments: - approved_comments: list[Comment] = [Comment.model_validate(comment) for comment in lecturer.comments if - comment.review_status is ReviewStatus.APPROVED] + 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: @@ -75,20 +77,14 @@ async def get_lecturer( return result - - - @lecturer.get("", response_model=LecturerGetAll) async def get_lecturers( limit: int = 10, offset: int = 0, - - info: list[Literal["comments", "mark"]] = Query( - default=[] - ), + 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)) + _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)), ) -> LecturerGetAll: """ Scopes: `["rating.lecturer.read"]` @@ -112,22 +108,32 @@ async def get_lecturers( if not lecturers: raise ObjectNotFound(Lecturer, 'all') result = LecturerGetAll(limit=limit, offset=offset, total=len(lecturers)) - for db_lecturer in lecturers[offset: limit+offset]: + 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[Comment] = [Comment.model_validate(comment) for comment in db_lecturer.comments if - comment.review_status is ReviewStatus.APPROVED] + 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) + approved_comments + ) lecturer_to_result.mark_kindness = sum(comment.mark_kindness for comment in approved_comments) / len( - approved_comments) + 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] + 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 @@ -136,22 +142,22 @@ async def get_lecturers( 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.read"], allow_none=False, auto_error=True)) + _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)), ) -> LecturerGet: """ Scopes: `["auth.scope.update"]` """ lecturer = Lecturer.get(id, session=db.session) + if lecturer is None: + raise ObjectNotFound(Lecturer, id) result = LecturerGet.model_validate( Lecturer.update(lecturer.id, **lecturer_info.model_dump(exclude_unset=True), session=db.session) ) @@ -161,11 +167,15 @@ async def update_lecturer( @lecturer.delete("/{id}", response_model=StatusResponseModel) async def delete_lecturer( - id: int, - _=Depends(UnionAuth(scopes=["rating.lecturer.delete"], allow_none=False, auto_error=True)) + 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) Lecturer.delete(session=db.session, id=id) - return StatusResponseModel(status="Success", message="Lecturer has been deleted", ru="Преподаватель удален из RatingAPI") \ No newline at end of file + return StatusResponseModel( + status="Success", message="Lecturer has been deleted", ru="Преподаватель удален из RatingAPI" + ) diff --git a/rating_api/schemas/base.py b/rating_api/schemas/base.py index ca49de4..fdb58a7 100644 --- a/rating_api/schemas/base.py +++ b/rating_api/schemas/base.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, ConfigDict + class Base(BaseModel): def __repr__(self) -> str: attrs = [] @@ -13,4 +14,4 @@ def __repr__(self) -> str: class StatusResponseModel(Base): status: str message: str - ru: str \ No newline at end of file + ru: str diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index a7aa972..5ed2c27 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -2,7 +2,8 @@ from rating_api.schemas.base import Base -class Comment(Base): + +class CommentGet(Base): id: int create_ts: datetime.datetime update_ts: datetime.datetime @@ -13,6 +14,27 @@ class Comment(Base): 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 @@ -25,7 +47,8 @@ class LecturerGet(Base): mark_freebie: float | None = None mark_clarity: float | None = None mark_general: float | None = None - comments: list[Comment] | None = None + comments: list[CommentGet] | None = None + class LecturerGetAll(Base): lecturers: list[LecturerGet] = [] @@ -33,6 +56,7 @@ class LecturerGetAll(Base): offset: int total: int + class LecturerPost(Base): first_name: str last_name: str @@ -41,6 +65,7 @@ class LecturerPost(Base): avatar_link: str | None = None timetable_id: int + class LecturerPatch(Base): first_name: str | None = None last_name: str | None = None diff --git a/rating_api/settings.py b/rating_api/settings.py index b2a1ceb..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] = ['*'] From e7178ba808f6d5bb1e14fcc5f53709a7d829404a Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 03:40:49 +0300 Subject: [PATCH 03/12] Linting --- migrations/versions/dbe6ca79a40d_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/migrations/versions/dbe6ca79a40d_init.py b/migrations/versions/dbe6ca79a40d_init.py index 45874df..0e369ca 100644 --- a/migrations/versions/dbe6ca79a40d_init.py +++ b/migrations/versions/dbe6ca79a40d_init.py @@ -5,6 +5,7 @@ Create Date: 2024-10-16 23:21:37.960911 """ + import sqlalchemy as sa from alembic import op From 92ba0762c036f5fe59b6d6093f66263edf4a0aa5 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 03:43:17 +0300 Subject: [PATCH 04/12] makefile fix --- Makefile | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 3f85a5c..c6a2d1c 100644 --- a/Makefile +++ b/Makefile @@ -1,19 +1,19 @@ run: - uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app + source ./venv/bin/activate && uvicorn --reload --log-config logging_dev.conf rating_api.routes.base:app -#configure: venv -# source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt -# -#venv: -# python3.11 -m venv venv -# -#format: -# autoflake -r --in-place --remove-all-unused-imports ./rating_api -# isort ./rating_api -# black ./rating_api -# -#db: -# docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 -# -#migrate: -# alembic upgrade head +configure: venv + source ./venv/bin/activate && pip install -r requirements.dev.txt -r requirements.txt + +venv: + python3.11 -m venv venv + +format: + autoflake -r --in-place --remove-all-unused-imports ./rating_api + isort ./rating_api + black ./rating_api + +db: + docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-rating_api postgres:15 + +migrate: + alembic upgrade head From 2a127bf5c472e74b102a7494d84e6800f0548299 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 03:44:59 +0300 Subject: [PATCH 05/12] lecturer scopes fix --- rating_api/routes/lecturer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index 9056249..1255f7c 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -150,10 +150,10 @@ async def get_lecturers( async def update_lecturer( id: int, lecturer_info: LecturerPatch, - _=Depends(UnionAuth(scopes=["rating.lecturer.read"], allow_none=False, auto_error=True)), + _=Depends(UnionAuth(scopes=["rating.lecturer.update"], allow_none=False, auto_error=True)), ) -> LecturerGet: """ - Scopes: `["auth.scope.update"]` + Scopes: `["rating.lecturer.update"]` """ lecturer = Lecturer.get(id, session=db.session) if lecturer is None: From c2de3dec039ba4e6a3dd54cd34d80e0090374b15 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 03:47:30 +0300 Subject: [PATCH 06/12] refactor fixes --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 62c9320..f520c68 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -58,7 +58,7 @@ jobs: with: requirementsFiles: "requirements.txt requirements.dev.txt" - uses: psf/black@stable - - name: CommentGet if linting failed + - name: Comment if linting failed if: ${{ failure() }} uses: thollander/actions-comment-pull-request@v2 with: From c24983e700465ae3ee6578f48fef12a924e99240 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 15:32:15 +0300 Subject: [PATCH 07/12] Add uuid to comment. Fixes --- migrations/env.py | 2 +- .../656228b2d6e0_delete_id_from_comment.py | 23 ++++++++ .../7354951f8e4c_add_uuid_to_comment.py | 25 +++++++++ migrations/versions/dbe6ca79a40d_init.py | 7 +-- models/base.py | 7 ++- models/db.py | 5 +- rating_api/routes/comment.py | 52 ++++++++----------- rating_api/routes/lecturer.py | 25 ++++++--- rating_api/schemas/models.py | 2 +- 9 files changed, 98 insertions(+), 50 deletions(-) create mode 100644 migrations/versions/656228b2d6e0_delete_id_from_comment.py create mode 100644 migrations/versions/7354951f8e4c_add_uuid_to_comment.py diff --git a/migrations/env.py b/migrations/env.py index 16c5051..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 models import * +from models.base import BaseDbModel from rating_api.settings import get_settings 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..26896ba --- /dev/null +++ b/migrations/versions/656228b2d6e0_delete_id_from_comment.py @@ -0,0 +1,23 @@ +"""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..3ad318c --- /dev/null +++ b/migrations/versions/7354951f8e4c_add_uuid_to_comment.py @@ -0,0 +1,25 @@ +"""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 index 0e369ca..3b399c5 100644 --- a/migrations/versions/dbe6ca79a40d_init.py +++ b/migrations/versions/dbe6ca79a40d_init.py @@ -1,7 +1,7 @@ """init Revision ID: dbe6ca79a40d -Revises: +Revises: Create Date: 2024-10-16 23:21:37.960911 """ @@ -10,7 +10,6 @@ from alembic import op -# revision identifiers, used by Alembic. revision = 'dbe6ca79a40d' down_revision = None branch_labels = None @@ -18,7 +17,6 @@ def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.create_table( 'lecturer', sa.Column('id', sa.Integer(), nullable=False), @@ -66,12 +64,9 @@ def upgrade(): ), sa.PrimaryKeyConstraint('id'), ) - # ### end Alembic commands ### def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### op.drop_table('lecturer_user_comment') op.drop_table('comment') op.drop_table('lecturer') - # ### end Alembic commands ### diff --git a/models/base.py b/models/base.py index d85a9d5..28f39d4 100644 --- a/models/base.py +++ b/models/base.py @@ -29,7 +29,6 @@ def __repr__(self): class BaseDbModel(Base): __abstract__ = True - id: Mapped[int] = mapped_column(Integer, primary_key=True) @classmethod def create(cls, *, session: Session, **kwargs) -> BaseDbModel: @@ -47,7 +46,7 @@ def query(cls, *, with_deleted: bool = False, session: Session) -> Query: return objs @classmethod - def get(cls, id: int, *, with_deleted=False, session: Session) -> BaseDbModel: + 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"): @@ -58,7 +57,7 @@ def get(cls, id: int, *, with_deleted=False, session: Session) -> BaseDbModel: raise ObjectNotFound(cls, id) @classmethod - def update(cls, id: int, *, session: Session, **kwargs) -> BaseDbModel: + 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) @@ -66,7 +65,7 @@ def update(cls, id: int, *, session: Session, **kwargs) -> BaseDbModel: return obj @classmethod - def delete(cls, id: int, *, session: Session) -> None: + 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"): diff --git a/models/db.py b/models/db.py index e40ddb4..76bded5 100644 --- a/models/db.py +++ b/models/db.py @@ -2,9 +2,10 @@ import datetime import logging +import uuid from enum import Enum -from sqlalchemy import Boolean, DateTime +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 @@ -36,7 +37,7 @@ class Lecturer(BaseDbModel): class Comment(BaseDbModel): - id: Mapped[int] = mapped_column(Integer, primary_key=True) + 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) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index c5e38d3..bb08a98 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -1,13 +1,9 @@ import datetime from typing import Annotated, Literal -# from auth_backend.base import StatusResponseModel -# from auth_backend.models.db import Scope, UserSession -# from auth_backend.schemas.models import ScopeGet, ScopePatch, ScopePost from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db -from sqlalchemy import and_, func from models import Comment, Lecturer, LecturerUserComment, ReviewStatus from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, TooManyCommentRequests @@ -52,26 +48,21 @@ async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depen - datetime.datetime.utcnow() ) - LecturerUserComment.create( - session=db.session, - **LecturerUserCommentPost( - **comment_info.dict(exclude_unset=True), lecturer_id=lecturer_id, user_id=user.get('id') - ).dict(), - ) + LecturerUserComment.create(session=db.session, lecturer_id=lecturer_id, user_id=user.get('id')) new_comment = Comment.create( - session=db.session, **comment_info.dict(), lecturer_id=lecturer_id, review_status=ReviewStatus.PENDING + session=db.session, **comment_info.model_dump(), lecturer_id=lecturer_id, review_status=ReviewStatus.PENDING ) return CommentGet.model_validate(new_comment) -@comment.get("/{id}", response_model=CommentGet) -async def get_comment(id: int) -> CommentGet: +@comment.get("/{uuid}", response_model=CommentGet) +async def get_comment(uuid: str) -> CommentGet: """ - Возвращает комментарий по его ID в базе данных RatingAPI + Возвращает комментарий по его UUID в базе данных RatingAPI """ - comment: Comment = Comment.query(session=db.session).filter(Comment.id == id).one_or_none() + comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == uuid).one_or_none() if comment is None: - raise ObjectNotFound(Comment, id) + raise ObjectNotFound(Comment, uuid) return CommentGet.model_validate(comment) @@ -89,7 +80,9 @@ async def get_comments( `limit` - максимальное количество возвращаемых комментариев - `offset` - нижняя граница получения комментариев, т.е. если по дефолту первым возвращается комментарий с условным номером N, то при наличии ненулевого offset будет возвращаться комментарий с номером N + offset + `offset` - смещение, определяющее, с какого по порядку комментария начинать выборку. + Если без смещения возвращается комментарий с условным номером N, + то при значении offset = X будет возвращаться комментарий с номером N + X `order_by` - возможное значение `'create_ts'` - возвращается список комментариев отсортированных по времени создания @@ -121,40 +114,39 @@ async def get_comments( return result -@comment.patch("/{id}", response_model=CommentGet) +@comment.patch("/{uuid}", response_model=CommentGet) async def review_comment( - id: int, + uuid: str, 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"]` - Проверка комментария и присваивания ему статуса по его ID в базе данных RatingAPI + Проверка комментария и присваивания ему статуса по его UUID в базе данных RatingAPI `review_status` - возможные значения `approved` - комментарий одобрен и возвращается при запросе лектора `dismissed` - комментарий отклонен, не отображается в запросе лектора """ - check_comment: Comment = Comment.query(session=db.session).filter(Comment.id == id).one_or_none() + check_comment: Comment = Comment.query(session=db.session).filter(Comment.uuid == uuid).one_or_none() if not check_comment: - raise ObjectNotFound(Comment, id) - return CommentGet.model_validate(Comment.update(session=db.session, id=id, review_status=review_status)) + raise ObjectNotFound(Comment, uuid) + return CommentGet.model_validate(Comment.update(session=db.session, id=uuid, review_status=review_status)) -@comment.delete("/{id}", response_model=StatusResponseModel) +@comment.delete("/{uuid}", response_model=StatusResponseModel) async def delete_comment( - id: int, - # _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) + uuid: str, _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) ): """ Scopes: `["rating.comment.delete"]` - Удаляет комментарий по его ID в базе данных RatingAPI + Удаляет комментарий по его UUID в базе данных RatingAPI """ - check_comment = Comment.get(session=db.session, id=id) + check_comment = Comment.get(session=db.session, id=uuid) if check_comment is None: - raise ObjectNotFound(Comment, id) - Comment.delete(session=db.session, id=id) + 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/lecturer.py b/rating_api/routes/lecturer.py index 1255f7c..6697764 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -1,14 +1,11 @@ from typing import Literal -# from auth_backend.base import StatusResponseModel -# from auth_backend.models.db import Scope, UserSession -# from auth_backend.schemas.models import ScopeGet, ScopePatch, ScopePost from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db -from sqlalchemy import and_, func +from sqlalchemy import and_ -from models import Lecturer, ReviewStatus +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 @@ -31,7 +28,7 @@ async def create_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.dict()) + 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) @@ -158,6 +155,22 @@ async def update_lecturer( lecturer = Lecturer.get(id, session=db.session) if lecturer is None: raise ObjectNotFound(Lecturer, id) + + for comment in lecturer.comments: + Comment.delete(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) + + 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) ) diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index 5ed2c27..9f0a9c5 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -4,7 +4,7 @@ class CommentGet(Base): - id: int + uuid: str create_ts: datetime.datetime update_ts: datetime.datetime subject: str From c80b4c74a1800527f8c7e681cf360863ff8ef577 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 15:33:55 +0300 Subject: [PATCH 08/12] Linting --- migrations/versions/656228b2d6e0_delete_id_from_comment.py | 2 +- migrations/versions/7354951f8e4c_add_uuid_to_comment.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/migrations/versions/656228b2d6e0_delete_id_from_comment.py b/migrations/versions/656228b2d6e0_delete_id_from_comment.py index 26896ba..ab94567 100644 --- a/migrations/versions/656228b2d6e0_delete_id_from_comment.py +++ b/migrations/versions/656228b2d6e0_delete_id_from_comment.py @@ -5,10 +5,10 @@ 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 diff --git a/migrations/versions/7354951f8e4c_add_uuid_to_comment.py b/migrations/versions/7354951f8e4c_add_uuid_to_comment.py index 3ad318c..d9ea6a9 100644 --- a/migrations/versions/7354951f8e4c_add_uuid_to_comment.py +++ b/migrations/versions/7354951f8e4c_add_uuid_to_comment.py @@ -5,6 +5,7 @@ Create Date: 2024-10-17 15:25:02.529966 """ + import sqlalchemy as sa from alembic import op From 7c24d94f6f0b0b36172709ec231e58f85f7db102 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 15:35:58 +0300 Subject: [PATCH 09/12] Linting --- migrations/versions/656228b2d6e0_delete_id_from_comment.py | 1 + 1 file changed, 1 insertion(+) diff --git a/migrations/versions/656228b2d6e0_delete_id_from_comment.py b/migrations/versions/656228b2d6e0_delete_id_from_comment.py index ab94567..0eebe9f 100644 --- a/migrations/versions/656228b2d6e0_delete_id_from_comment.py +++ b/migrations/versions/656228b2d6e0_delete_id_from_comment.py @@ -9,6 +9,7 @@ import sqlalchemy as sa from alembic import op + revision = '656228b2d6e0' down_revision = '7354951f8e4c' branch_labels = None From e472916faa1274d03b63ace40dcbbde50ccfa9af Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 17:35:44 +0300 Subject: [PATCH 10/12] Adding UUID --- models/base.py | 2 ++ models/db.py | 2 +- rating_api/routes/comment.py | 8 ++++---- rating_api/routes/lecturer.py | 14 +++++++------- rating_api/schemas/models.py | 4 ++-- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/models/base.py b/models/base.py index 28f39d4..678078a 100644 --- a/models/base.py +++ b/models/base.py @@ -52,6 +52,8 @@ def get(cls, id: int | str, *, with_deleted=False, session: Session) -> BaseDbMo 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) diff --git a/models/db.py b/models/db.py index 76bded5..c39495d 100644 --- a/models/db.py +++ b/models/db.py @@ -37,7 +37,7 @@ class Lecturer(BaseDbModel): class Comment(BaseDbModel): - uuid: Mapped[uuid.UUID] = mapped_column(UUID, primary_key=True, default=uuid.uuid4()) + 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) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index bb08a98..f02370f 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -4,7 +4,7 @@ from auth_lib.fastapi import UnionAuth from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_sqlalchemy import db - +from uuid import UUID from models import Comment, Lecturer, LecturerUserComment, ReviewStatus from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, TooManyCommentRequests from rating_api.schemas.base import StatusResponseModel @@ -56,7 +56,7 @@ async def create_comment(lecturer_id: int, comment_info: CommentPost, user=Depen @comment.get("/{uuid}", response_model=CommentGet) -async def get_comment(uuid: str) -> CommentGet: +async def get_comment(uuid: UUID) -> CommentGet: """ Возвращает комментарий по его UUID в базе данных RatingAPI """ @@ -116,7 +116,7 @@ async def get_comments( @comment.patch("/{uuid}", response_model=CommentGet) async def review_comment( - uuid: str, + uuid: UUID, review_status: Literal[ReviewStatus.APPROVED, ReviewStatus.DISMISSED] = ReviewStatus.DISMISSED, _=Depends(UnionAuth(scopes=["rating.comment.review"], allow_none=False, auto_error=True)), ) -> CommentGet: @@ -136,7 +136,7 @@ async def review_comment( @comment.delete("/{uuid}", response_model=StatusResponseModel) async def delete_comment( - uuid: str, _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) + uuid: UUID, _=Depends(UnionAuth(scopes=["rating.comment.delete"], allow_none=False, auto_error=True)) ): """ Scopes: `["rating.comment.delete"]` diff --git a/rating_api/routes/lecturer.py b/rating_api/routes/lecturer.py index 6697764..92cbaf6 100644 --- a/rating_api/routes/lecturer.py +++ b/rating_api/routes/lecturer.py @@ -156,13 +156,6 @@ async def update_lecturer( if lecturer is None: raise ObjectNotFound(Lecturer, id) - for comment in lecturer.comments: - Comment.delete(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) - check_timetable_id = ( Lecturer.query(session=db.session) .filter(and_(Lecturer.timetable_id == lecturer_info.timetable_id, Lecturer.id != id)) @@ -188,6 +181,13 @@ async def delete_lecturer( 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/schemas/models.py b/rating_api/schemas/models.py index 9f0a9c5..51c37ea 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -1,10 +1,10 @@ import datetime from rating_api.schemas.base import Base - +from uuid import UUID class CommentGet(Base): - uuid: str + uuid: UUID create_ts: datetime.datetime update_ts: datetime.datetime subject: str From f4a98af238ddb21a58c2048cfa66087858b91029 Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 17:36:05 +0300 Subject: [PATCH 11/12] Adding Tests --- tests/conftest.py | 30 ++++++ tests/test_routes/test_comment.py | 120 ++++++++++++++++++++++ tests/test_routes/test_lecturer.py | 155 +++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 tests/test_routes/test_comment.py create mode 100644 tests/test_routes/test_lecturer.py diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..3265d39 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() \ No newline at end of file diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py new file mode 100644 index 0000000..49bf74d --- /dev/null +++ b/tests/test_routes/test_comment.py @@ -0,0 +1,120 @@ +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 Lecturer, ReviewStatus, Comment, LecturerUserComment +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..7739d62 --- /dev/null +++ b/tests/test_routes/test_lecturer.py @@ -0,0 +1,155 @@ +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 Lecturer, ReviewStatus, Comment +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 From fa935396cebb2af62c024549b22cdd80e2d173fe Mon Sep 17 00:00:00 2001 From: Artem Morozov Date: Thu, 17 Oct 2024 17:37:11 +0300 Subject: [PATCH 12/12] Linting --- rating_api/routes/comment.py | 3 +- rating_api/schemas/models.py | 3 +- tests/conftest.py | 2 +- tests/test_routes/test_comment.py | 34 ++++++++++----------- tests/test_routes/test_lecturer.py | 47 ++++++++++++------------------ 5 files changed, 38 insertions(+), 51 deletions(-) diff --git a/rating_api/routes/comment.py b/rating_api/routes/comment.py index f02370f..6ff501d 100644 --- a/rating_api/routes/comment.py +++ b/rating_api/routes/comment.py @@ -1,10 +1,11 @@ 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 uuid import UUID + from models import Comment, Lecturer, LecturerUserComment, ReviewStatus from rating_api.exceptions import AlreadyExists, ForbiddenAction, ObjectNotFound, TooManyCommentRequests from rating_api.schemas.base import StatusResponseModel diff --git a/rating_api/schemas/models.py b/rating_api/schemas/models.py index 51c37ea..90b8f0b 100644 --- a/rating_api/schemas/models.py +++ b/rating_api/schemas/models.py @@ -1,7 +1,8 @@ import datetime +from uuid import UUID from rating_api.schemas.base import Base -from uuid import UUID + class CommentGet(Base): uuid: UUID diff --git a/tests/conftest.py b/tests/conftest.py index 3265d39..cc467ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,4 +27,4 @@ def dbsession() -> Session: settings = Settings() engine = create_engine(str(settings.DB_DSN), pool_pre_ping=True) TestingSessionLocal = sessionmaker(bind=engine) - yield TestingSessionLocal() \ No newline at end of file + yield TestingSessionLocal() diff --git a/tests/test_routes/test_comment.py b/tests/test_routes/test_comment.py index 49bf74d..88b8d56 100644 --- a/tests/test_routes/test_comment.py +++ b/tests/test_routes/test_comment.py @@ -7,22 +7,19 @@ from starlette import status from starlette.testclient import TestClient -from models import Lecturer, ReviewStatus, Comment, LecturerUserComment +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 - } + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} lecturer: Lecturer = Lecturer(**body) dbsession.add(lecturer) dbsession.commit() @@ -34,16 +31,18 @@ def test_create_comment(client, dbsession): "mark_freebie": -2, "mark_clarity": 0, } - params = { - "lecturer_id": lecturer.id - } + 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() + 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) @@ -52,6 +51,7 @@ def test_create_comment(client, dbsession): 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": 'Иван', @@ -70,7 +70,7 @@ def test_get_comment(client, dbsession): "mark_kindness": 1, "mark_freebie": -2, "mark_clarity": 0, - "review_status": ReviewStatus.APPROVED + "review_status": ReviewStatus.APPROVED, } comment: Comment = Comment(**body) dbsession.add(comment) @@ -86,13 +86,9 @@ def test_get_comment(client, dbsession): dbsession.delete(lecturer) dbsession.commit() + def test_delete_comment(client, dbsession): - body = { - "first_name": 'Иван', - "last_name": 'Иванов', - "middle_name": 'Иванович', - "timetable_id": 0 - } + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} lecturer: Lecturer = Lecturer(**body) dbsession.add(lecturer) dbsession.commit() @@ -104,7 +100,7 @@ def test_delete_comment(client, dbsession): "mark_kindness": 1, "mark_freebie": -2, "mark_clarity": 0, - "review_status": ReviewStatus.APPROVED + "review_status": ReviewStatus.APPROVED, } comment: Comment = Comment(**body) dbsession.add(comment) diff --git a/tests/test_routes/test_lecturer.py b/tests/test_routes/test_lecturer.py index 7739d62..d4b4fc3 100644 --- a/tests/test_routes/test_lecturer.py +++ b/tests/test_routes/test_lecturer.py @@ -6,22 +6,19 @@ from starlette import status from starlette.testclient import TestClient -from models import Lecturer, ReviewStatus, Comment +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 - } + 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) @@ -33,13 +30,9 @@ def test_create_lecturer(client, dbsession): 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 - } + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} lecturer: Lecturer = Lecturer(**body) dbsession.add(lecturer) dbsession.commit() @@ -57,13 +50,9 @@ def test_get_lecturer(client, dbsession): dbsession.delete(lecturer) dbsession.commit() + def test_get_lecturer_with_comments(client, dbsession): - body = { - "first_name": 'Иван', - "last_name": 'Иванов', - "middle_name": 'Иванович', - "timetable_id": 0 - } + body = {"first_name": 'Иван', "last_name": 'Иванов', "middle_name": 'Иванович', "timetable_id": 0} lecturer: Lecturer = Lecturer(**body) dbsession.add(lecturer) dbsession.commit() @@ -71,13 +60,13 @@ def test_get_lecturer_with_comments(client, dbsession): 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 + "subject": "Физика", + "text": "Хороший преподаватель", + "mark_kindness": 2, + "mark_freebie": 0, + "mark_clarity": 2, + "lecturer_id": db_lecturer.id, + "review_status": ReviewStatus.APPROVED, } comment2: dict = { "subject": "Физика", @@ -86,7 +75,7 @@ def test_get_lecturer_with_comments(client, dbsession): "mark_freebie": 1, "mark_clarity": -1, "lecturer_id": db_lecturer.id, - "review_status": ReviewStatus.APPROVED + "review_status": ReviewStatus.APPROVED, } comment3: dict = { "subject": "Физика", @@ -95,7 +84,7 @@ def test_get_lecturer_with_comments(client, dbsession): "mark_freebie": 2, "mark_clarity": 2, "lecturer_id": db_lecturer.id, - "review_status": ReviewStatus.PENDING + "review_status": ReviewStatus.PENDING, } comment1: Comment = Comment.create(session=dbsession, **comment1) comment2: Comment = Comment.create(session=dbsession, **comment2) @@ -106,7 +95,6 @@ def test_get_lecturer_with_comments(client, dbsession): assert comment3 is not None query = { "info": ['comments', 'mark'], - } response = client.get(f'{url}/{db_lecturer.id}', params=query) print(response.json()) @@ -119,6 +107,7 @@ def test_get_lecturer_with_comments(client, dbsession): assert json_response["subject"] == "Физика" assert len(json_response["comments"]) != 0 + def test_update_lecturer(client, dbsession): body = { "first_name": 'Алексей',