nspotapov/back (#4)

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2025-06-10 22:48:15 +04:00
parent 42dd9124be
commit c791e55e38
29 changed files with 239 additions and 279 deletions

View File

@@ -0,0 +1 @@
from .router import router

View File

@@ -1,26 +0,0 @@
from app.dao.base import BaseDAO
from app.models import *
class DeansDAO(BaseDAO):
model = Dean
class FacultsDAO(BaseDAO):
model = Facult
class StudentsDAO(BaseDAO):
model = Student
class SpecializationsDAO(BaseDAO):
model = Specialization
class DeansDAO(BaseDAO):
model = Dean
class GroupsDAO(BaseDAO):
model = Group

View File

@@ -1,27 +1,26 @@
from typing import List
from fastapi import APIRouter, Response, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Dean
from app.auth.utils import authenticate_user, set_tokens
from app.dao import UsersDAO
from app.dependencies.auth_dep import (
get_current_user,
check_refresh_token,
)
from app.dependencies.dao_dep import get_session_with_commit, get_session_without_commit
from app.exceptions import UserAlreadyExistsException, IncorrectEmailOrPasswordException
from app.auth.dao import DeansDAO
from app.auth.schemas import SDeanRegister, SDeanAuth, EmailModel, SDeanAddDB, SDeanInfo
from app.models import User
from app.schemas import SUserRegister, SUserAuth, EmailModel, SUserAddDB, SUserInfo
router = APIRouter()
@router.post("/register/")
@router.post("/register")
async def register_user(
user_data: SDeanRegister, session: AsyncSession = Depends(get_session_with_commit)
user_data: SUserRegister, session: AsyncSession = Depends(get_session_with_commit)
) -> dict:
# Проверка существования пользователя
user_dao = DeansDAO(session)
user_dao = UsersDAO(session)
existing_user = await user_dao.find_one_or_none(
filters=EmailModel(email=user_data.email)
@@ -34,24 +33,24 @@ async def register_user(
user_data_dict.pop("confirm_password", None)
# Добавление пользователя
await user_dao.add(values=SDeanAddDB(**user_data_dict))
await user_dao.add(values=SUserAddDB(**user_data_dict))
return {"message": "Вы успешно зарегистрированы!"}
@router.post("/login/")
@router.post("/login")
async def auth_user(
response: Response,
user_data: SDeanAuth,
user_data: SUserAuth,
session: AsyncSession = Depends(get_session_without_commit),
) -> dict:
users_dao = DeansDAO(session)
users_dao = UsersDAO(session)
user = await users_dao.find_one_or_none(filters=EmailModel(email=user_data.email))
if not (user and await authenticate_user(user=user, password=user_data.password)):
raise IncorrectEmailOrPasswordException
set_tokens(response, user.id)
return {"ok": True, "message": "Авторизация успешна!"}
return {"message": "Авторизация успешна!"}
@router.post("/logout")
@@ -61,14 +60,14 @@ async def logout(response: Response):
return {"message": "Пользователь успешно вышел из системы"}
@router.get("/me/")
async def get_me(user_data: Dean = Depends(get_current_user)) -> SDeanInfo:
return SDeanInfo.model_validate(user_data)
@router.get("/me")
async def get_me(user_data: User = Depends(get_current_user)) -> SUserInfo:
return SUserInfo.model_validate(user_data)
@router.post("/refresh")
async def process_refresh_token(
response: Response, user: Dean = Depends(check_refresh_token)
response: Response, user: User = Depends(check_refresh_token)
):
set_tokens(response, user.id)
return {"message": "Токены успешно обновлены"}

View File

@@ -1,9 +1,14 @@
import os
from pydantic_settings import BaseSettings
BASE_DIR: str = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if os.path.exists(os.path.join(BASE_DIR, ".env")):
import dotenv
dotenv.load_dotenv(os.path.join(BASE_DIR, ".env"))
class Settings(BaseSettings):
BASE_DIR: str = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
BASE_DIR: str = BASE_DIR
DB_HOST: str = os.getenv("DB_HOST")
DB_PORT: str = os.getenv("DB_PORT")
DB_DRIVER: str = os.getenv("DB_DRIVER")

View File

@@ -0,0 +1,3 @@
from .database import async_session_maker
from .user import UsersDAO

View File

@@ -1,13 +1,15 @@
from typing import List, TypeVar, Generic, Type
from pydantic import BaseModel
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.future import select
from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete, func
from loguru import logger
from sqlalchemy.ext.asyncio import AsyncSession
from .database import Base
T = TypeVar("T", bound=Base)
from loguru import logger
from pydantic import BaseModel
from sqlalchemy import update as sqlalchemy_update, delete as sqlalchemy_delete, func
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models import Model
T = TypeVar("T", bound=Model)
class BaseDAO(Generic[T]):

View File

@@ -1,62 +1,13 @@
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Annotated
from sqlalchemy import Integer, inspect
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, declared_attr
from sqlalchemy.ext.asyncio import (
AsyncAttrs,
async_sessionmaker,
create_async_engine,
AsyncSession,
)
from app.config import database_url
engine = create_async_engine(url=database_url)
async_session_maker = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
str_uniq = Annotated[str, mapped_column(unique=True, nullable=False)]
class Base(AsyncAttrs, DeclarativeBase):
__abstract__ = True
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
def to_dict(self, exclude_none: bool = False):
"""
Преобразует объект модели в словарь.
Args:
exclude_none (bool): Исключать ли None значения из результата
Returns:
dict: Словарь с данными объекта
"""
result = {}
for column in inspect(self.__class__).columns:
value = getattr(self, column.key)
# Преобразование специальных типов данных
if isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, Decimal):
value = float(value)
elif isinstance(value, uuid.UUID):
value = str(value)
# Добавляем значение в результат
if not exclude_none or value is not None:
result[column.key] = value
return result
def __repr__(self) -> str:
"""Строковое представление объекта для удобства отладки."""
return f"<{self.__class__.__name__}(id={self.id})>"

6
backend/app/dao/user.py Normal file
View File

@@ -0,0 +1,6 @@
from app.models import User
from .base import BaseDAO
class UsersDAO(BaseDAO):
model = User

View File

@@ -3,8 +3,8 @@ from fastapi import Request, Depends
from jose import jwt, JWTError, ExpiredSignatureError
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dao import DeansDAO
from app.models import Dean
from app.dao import UsersDAO
from app.models import User
from app.config import settings
from app.dependencies.dao_dep import get_session_without_commit
from app.exceptions import (
@@ -36,7 +36,7 @@ def get_refresh_token(request: Request) -> str:
async def check_refresh_token(
token: str = Depends(get_refresh_token),
session: AsyncSession = Depends(get_session_without_commit),
) -> Dean:
) -> User:
"""Проверяем refresh_token и возвращаем пользователя."""
try:
payload = jwt.decode(
@@ -46,7 +46,7 @@ async def check_refresh_token(
if not user_id:
raise NoJwtException
user = await DeansDAO(session).find_one_or_none_by_id(data_id=int(user_id))
user = await UsersDAO(session).find_one_or_none_by_id(data_id=int(user_id))
if not user:
raise NoJwtException
@@ -58,7 +58,7 @@ async def check_refresh_token(
async def get_current_user(
token: str = Depends(get_access_token),
session: AsyncSession = Depends(get_session_without_commit),
) -> Dean:
) -> User:
"""Проверяем access_token и возвращаем пользователя."""
try:
# Декодируем токен
@@ -80,16 +80,7 @@ async def get_current_user(
if not user_id:
raise NoUserIdException
user = await DeansDAO(session).find_one_or_none_by_id(data_id=int(user_id))
user = await UsersDAO(session).find_one_or_none_by_id(data_id=int(user_id))
if not user:
raise UserNotFoundException
return user
async def get_current_admin_user(
current_user: Dean = Depends(get_current_user),
) -> Dean:
"""Проверяем права пользователя как администратора."""
if current_user.role.id in [3, 4]:
return current_user
raise ForbiddenException

View File

@@ -1,6 +1,8 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession
from app.dao.database import async_session_maker
from app.dao import async_session_maker
async def get_session_with_commit() -> AsyncGenerator[AsyncSession, None]:

View File

@@ -1,11 +1,12 @@
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, APIRouter
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from loguru import logger
from app.auth.router import router as router_auth
from app.auth import router as auth_router
@asynccontextmanager
@@ -53,16 +54,9 @@ def create_app() -> FastAPI:
def register_routers(app: FastAPI) -> None:
"""Регистрация роутеров приложения."""
# Корневой роутер
root_router = APIRouter()
@root_router.get("/", tags=["root"])
def home_page():
return "Hello world"
# Подключение роутеров
app.include_router(root_router, tags=["root"])
app.include_router(router_auth, prefix="/auth", tags=["Auth"])
app.include_router(auth_router, prefix="/auth", tags=["Auth"])
# Создание экземпляра приложения

View File

@@ -1,11 +1,12 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.config import database_url
from app.dao.database import Base
from app.models import *
config = context.config
@@ -13,7 +14,7 @@ config.set_main_option("sqlalchemy.url", database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
target_metadata = Model.metadata
def run_migrations_offline() -> None:

View File

@@ -1,78 +0,0 @@
"""empty message
Revision ID: cae942c398cb
Revises:
Create Date: 2025-06-10 14:41:29.391395
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'cae942c398cb'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('deans',
sa.Column('email', sa.String(length=50), nullable=False),
sa.Column('password', sa.String(length=256), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('surname', sa.String(length=50), nullable=False),
sa.Column('patronymic', sa.String(length=50), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('specializations',
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('code', sa.String(length=50), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code'),
sa.UniqueConstraint('name')
)
op.create_table('facults',
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('dean_id', sa.Integer(), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.ForeignKeyConstraint(['dean_id'], ['deans.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('groups',
sa.Column('course', sa.Integer(), nullable=False),
sa.Column('specialization_id', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.ForeignKeyConstraint(['specialization_id'], ['specializations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('students',
sa.Column('specialization_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('STUDY', 'ACADEM', 'EXPLUSION', 'GRADUATED', name='studentstatus'), nullable=False),
sa.Column('group_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('surname', sa.String(length=50), nullable=False),
sa.Column('patronymic', sa.String(length=50), nullable=True),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ),
sa.ForeignKeyConstraint(['specialization_id'], ['specializations.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('students')
op.drop_table('groups')
op.drop_table('facults')
op.drop_table('specializations')
op.drop_table('deans')
# ### end Alembic commands ###

View File

@@ -1,5 +1,6 @@
from .student import Student
from .group import Group
from .specialization import Specialization
from .dean import Dean
from .facult import Facult
from .grade_book import GradeBook
from .group import Group
from .model import Model
from .specialization import Specialization
from .user import User

View File

@@ -1,13 +0,0 @@
from sqlalchemy.orm import Mapped, relationship, mapped_column
from sqlalchemy import String
from app.dao.database import Base
from app.models.facult import Facult
from .mixins import HumanMixin
class Dean(HumanMixin, Base):
facult: Mapped[Facult | None] = relationship(back_populates="dean")
email: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password: Mapped[str] = mapped_column(String(256), nullable=False)

View File

@@ -0,0 +1,8 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from .model import Model
class Discipline(Model):
name: Mapped[str] = mapped_column(String(256), nullable=False, unique=True)

View File

@@ -1,10 +1,10 @@
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.dao.database import Base
from .model import Model
class Facult(Base):
class Facult(Model):
name: Mapped[str] = mapped_column(String(256), nullable=False, unique=True)
dean_id: Mapped[int | None] = mapped_column(ForeignKey("deans.id"), nullable=True)
dean: Mapped["Dean"] = relationship("Dean", back_populates="facult", lazy="joined")
dean_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
dean: Mapped["User"] = relationship("User", lazy="joined")

View File

@@ -1,10 +1,10 @@
import enum
from sqlalchemy import ForeignKey, Enum
from app.dao.database import Base
from .mixins import HumanMixin
from sqlalchemy import ForeignKey, Enum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .model import Model
class StudentStatus(enum.Enum):
STUDY = "Обучается"
@@ -13,15 +13,17 @@ class StudentStatus(enum.Enum):
GRADUATED = "Выпущен"
class Student(HumanMixin, Base):
specialization_id: Mapped[int] = mapped_column(ForeignKey("specializations.id"))
class GradeBook(Model):
student_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
student: Mapped["User"] = relationship("User", lazy="joined")
specialization_id: Mapped[int] = mapped_column(ForeignKey("specializations.id"), nullable=False)
specialization: Mapped["Specialization"] = relationship(
"Specialization", back_populates="groups", lazy="joined"
"Specialization", lazy="joined"
)
status: Mapped[StudentStatus] = mapped_column(
Enum(StudentStatus), nullable=False, default=StudentStatus.STUDY
)
group_id: Mapped[int] = mapped_column(ForeignKey("groups.id"))
group: Mapped["Group"] = relationship(
"Group", back_populates="students", lazy="joined"
"Group", lazy="joined"
)

View File

@@ -1,12 +1,15 @@
from sqlalchemy import ForeignKey, Integer
from app.dao.database import Base
from sqlalchemy import ForeignKey, Integer, CheckConstraint, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .model import Model
class Group(Base):
course: Mapped[int] = mapped_column(Integer, nullable=False)
students: Mapped[list["Student"]] = relationship(back_populates="group")
class Group(Model):
course: Mapped[int] = mapped_column(Integer, CheckConstraint("course > 0"), nullable=False)
specialization_id: Mapped[int] = mapped_column(ForeignKey("specializations.id"))
specialization: Mapped["Specialization"] = relationship(
"Specialization", back_populates="groups", lazy="joined"
"Specialization", lazy="joined"
)
max_students_count: Mapped[int] = mapped_column(Integer, CheckConstraint("max_students_count >= 0"), nullable=False)
number: Mapped[int] = mapped_column(Integer, CheckConstraint("number > 0"), nullable=False)
__table_args__ = (UniqueConstraint("course", "specialization_id", "number", name="_spec_parallel_groups_unique"),)

View File

@@ -1,10 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
class HumanMixin:
name: Mapped[str] = mapped_column(String(50), nullable=False)
surname: Mapped[str] = mapped_column(String(50), nullable=False)
patronymic: Mapped[str | None] = mapped_column(
String(50), nullable=True, default=None
)

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import Integer, inspect
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, declared_attr
class Model(AsyncAttrs, DeclarativeBase):
__abstract__ = True
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
@declared_attr
def __tablename__(cls) -> str:
return cls.__name__.lower() + "s"
def to_dict(self, exclude_none: bool = False):
"""
Преобразует объект модели в словарь.
Args:
exclude_none (bool): Исключать ли None значения из результата
Returns:
dict: Словарь с данными объекта
"""
result = {}
for column in inspect(self.__class__).columns:
value = getattr(self, column.key)
# Преобразование специальных типов данных
if isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, Decimal):
value = float(value)
elif isinstance(value, uuid.UUID):
value = str(value)
# Добавляем значение в результат
if not exclude_none or value is not None:
result[column.key] = value
return result
def __repr__(self) -> str:
"""Строковое представление объекта для удобства отладки."""
return f"<{self.__class__.__name__}(id={self.id})>"

View File

@@ -1,10 +1,9 @@
from sqlalchemy import String
from app.dao.database import Base
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from .model import Model
class Specialization(Base):
class Specialization(Model):
name: Mapped[str] = mapped_column(String(256), nullable=False, unique=True)
code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
students: Mapped[list["Student"]] = relationship(back_populates="specialization")
groups: Mapped[list["Group"]] = relationship(back_populates="specialization")

View File

@@ -0,0 +1,28 @@
import enum
from sqlalchemy import String, Enum
from sqlalchemy.orm import Mapped, mapped_column
from .model import Model
class UserRole(enum.Enum):
GUEST = "Гость"
STUDENT = "Студент"
TEACHER = "Преподаватель"
DEAN = "Декан"
RECTOR = "Ректор"
ADMIN = "Админ"
class User(Model):
email: Mapped[str] = mapped_column(String(50), unique=True, nullable=False)
password: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
surname: Mapped[str] = mapped_column(String(50), nullable=False)
patronymic: Mapped[str | None] = mapped_column(
String(50), nullable=True, default=None
)
role: Mapped[UserRole] = mapped_column(
Enum(UserRole), nullable=False, default=UserRole.GUEST
)

View File

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas import SUserInfo
from app.dependencies.dao_dep import get_session_with_commit, get_session_without_commit
router = APIRouter(prefix="/user")
@router.get("/{user_id}")
async def get_user_by_id(user_id: int, session: AsyncSession = Depends(get_session_without_commit)) -> SUserInfo:
pass
@router.get("/")
async def get_all_users(session: AsyncSession = Depends(get_session_without_commit)) -> list[SUserInfo]:
pass
@router.post("/")
async def create_user(user_data, session: AsyncSession = Depends(get_session_with_commit)) -> dict:
pass
@router.put("/{user_id}")
async def update_user(user_id: int, user_data, session: AsyncSession = Depends(get_session_with_commit)):
pass
@router.delete("/{user_id}")
async def delete_user(user_id: int, session: AsyncSession = Depends(get_session_with_commit)):
pass

View File

@@ -0,0 +1,2 @@
from .general import EmailModel
from .user import UserBase, SUserInfo, SUserAddDB, SUserRegister, SUserAuth

View File

@@ -0,0 +1,11 @@
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
)
class EmailModel(BaseModel):
email: EmailStr = Field(description="Электронная почта")
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,17 +1,13 @@
from typing import Self
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
model_validator,
)
from app.auth.utils import get_password_hash
class EmailModel(BaseModel):
email: EmailStr = Field(description="Электронная почта")
model_config = ConfigDict(from_attributes=True)
from app.models.user import UserRole
from .general import EmailModel
class UserBase(EmailModel):
@@ -27,9 +23,10 @@ class UserBase(EmailModel):
description="Отчество, от 3 до 50 символов",
default=None,
)
role: UserRole = Field(description="Роль пользователя в системе", default=UserRole.GUEST)
class SDeanRegister(UserBase):
class SUserRegister(UserBase):
password: str = Field(
min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков"
)
@@ -47,15 +44,15 @@ class SDeanRegister(UserBase):
return self
class SDeanAddDB(UserBase):
class SUserAddDB(UserBase):
password: str = Field(min_length=5, description="Пароль в формате HASH-строки")
class SDeanAuth(EmailModel):
class SUserAuth(EmailModel):
password: str = Field(
min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков"
)
class SDeanInfo(UserBase):
class SUserInfo(UserBase):
id: int = Field(description="Идентификатор пользователя")

View File

@@ -53,6 +53,6 @@ typer==0.16.0
typing_extensions==4.14.0
ujson==5.10.0
uvicorn==0.31.0
uvloop==0.21.0
# uvloop==0.21.0
watchfiles==1.0.5
websockets==15.0.1