Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand Down
24 changes: 24 additions & 0 deletions migrations/versions/656228b2d6e0_delete_id_from_comment.py
Original file line number Diff line number Diff line change
@@ -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))
26 changes: 26 additions & 0 deletions migrations/versions/7354951f8e4c_add_uuid_to_comment.py
Original file line number Diff line number Diff line change
@@ -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')
72 changes: 72 additions & 0 deletions migrations/versions/dbe6ca79a40d_init.py
Original file line number Diff line number Diff line change
@@ -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')
5 changes: 5 additions & 0 deletions models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .base import Base, BaseDbModel
from .db import *


__all__ = ["Base", "BaseDbModel", "Lecturer", "LecturerUserComment", "Comment"]
77 changes: 77 additions & 0 deletions models/base.py
Original file line number Diff line number Diff line change
@@ -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"(?<!^)(?=[A-Z])", "_", cls.__name__).lower()

def __repr__(self):
attrs = []
for c in self.__table__.columns:
attrs.append(f"{c.name}={getattr(self, c.name)}")
return "{}({})".format(c.__class__.__name__, ', '.join(attrs))


class BaseDbModel(Base):
__abstract__ = True

@classmethod
def create(cls, *, session: Session, **kwargs) -> 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()
58 changes: 58 additions & 0 deletions models/db.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
Temmmmmo marked this conversation as resolved.
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)
Comment thread
Temmmmmo marked this conversation as resolved.
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)
44 changes: 44 additions & 0 deletions rating_api/exceptions.py
Original file line number Diff line number Diff line change
@@ -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__}")
22 changes: 0 additions & 22 deletions rating_api/models/base.py

This file was deleted.

5 changes: 5 additions & 0 deletions rating_api/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import exc_handlers
from .base import app


__all__ = ["app", "exc_handlers"]
5 changes: 5 additions & 0 deletions rating_api/routes/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -31,3 +33,6 @@
allow_methods=settings.CORS_ALLOW_METHODS,
allow_headers=settings.CORS_ALLOW_HEADERS,
)

app.include_router(lecturer)
app.include_router(comment)
Loading