From 3d9cf40177f31aa7563ba6487a8f50b53cbbceae Mon Sep 17 00:00:00 2001 From: Shefler Date: Thu, 7 May 2026 03:30:25 +0300 Subject: [PATCH 1/3] Initial commit: Students API with FastAPI and SQLModel --- .env.example | 11 +++ .gitignore | 37 +++++++++ Dockerfile | 15 ++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/dependencies.py | 0 app/api/routes/__init__.py | 2 + app/api/routes/group.py | 76 +++++++++++++++++++ app/api/routes/student.py | 115 ++++++++++++++++++++++++++++ app/config.py | 27 +++++++ app/core/__init__.py | 0 app/core/exception.py | 53 +++++++++++++ app/database.py | 20 +++++ app/main.py | 85 +++++++++++++++++++++ app/models/__init__.py | 0 app/models/group.py | 20 +++++ app/models/student.py | 21 ++++++ app/schemas/__init__.py | 32 ++++++++ app/schemas/common.py | 6 ++ app/schemas/group.py | 28 +++++++ app/schemas/student.py | 35 +++++++++ app/services/__init__.py | 0 app/services/group.py | 75 +++++++++++++++++++ app/services/student.py | 149 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 36 +++++++++ readme.md | 82 ++++++++++++++++++++ requirements.txt | 8 ++ 27 files changed, 933 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/dependencies.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/group.py create mode 100644 app/api/routes/student.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/exception.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/group.py create mode 100644 app/models/student.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/common.py create mode 100644 app/schemas/group.py create mode 100644 app/schemas/student.py create mode 100644 app/services/__init__.py create mode 100644 app/services/group.py create mode 100644 app/services/student.py create mode 100644 docker-compose.yml create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd04d4b --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +#настройки базы данных +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=shefler_students_db + +# настройки приложения +APP_NAME=SheflerStudentsAPI +APP_VERSION=1.0.0 +DEBUG=True \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66d0439 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# среда разарботки +.vscode/ +.idea/ +*.swp +*.swo + +#среда +.env +.env.local + +# docker +*.log + +# база данных +*.db +*.sqlite + +# тестирование +.pytest_cache/ +.coverage +htmlcov/ + +#OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2dc29c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim +WORKDIR /app + +#установка системных зависимостей +RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/* + +# копирование и установка Python зависимостей +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +#копирование кода приложения +COPY ./app /app + +# запуск приложения +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..fa5a38e --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,2 @@ +from api.routes.student import router as students +from api.routes.group import router as groups \ No newline at end of file diff --git a/app/api/routes/group.py b/app/api/routes/group.py new file mode 100644 index 0000000..221c75f --- /dev/null +++ b/app/api/routes/group.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlmodel import Session +from typing import List + +from database import get_session +from services.group import GroupService +from schemas.group import GroupCreate, GroupUpdate, GroupResponse, GroupWithStudents +from schemas.common import MessageResponse + +router = APIRouter(prefix="/groups", tags=["Groups"]) + +@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +def sozdat_gruppu( + group_data: GroupCreate, + session: Session = Depends(get_session) +): + service = GroupService(session) + return service.sozdat_gruppu(group_data) + + +@router.get("/{group_id}", response_model=GroupWithStudents) +def poluchit_gruppu_po_id( + group_id: int, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.poluchit_gruppu_so_studentami(group_id) + + response = GroupWithStudents.model_validate(group) + response.students_count = len(group.students) + + return response + + +@router.get("/", response_model=List[GroupResponse]) +def poluchit_vse_gruppy( + skip: int = Query(0, ge=0, description="Количество пропускаемых с начала записей"), + limit: int = Query(100, ge=1, le=100, description="Максимальное количество записей"), + session: Session = Depends(get_session) +): + service = GroupService(session) + gruppy = service.poluchit_vse_gruppy(skip, limit) + + result = [] + for group in gruppy: + group_dict = group.model_dump() + group_dict['students_count'] = len(group.students) + result.append(GroupResponse(**group_dict)) + + return result + + +@router.put("/{group_id}", response_model=GroupResponse) +def obnovit_gruppu( + group_id: int, + group_data: GroupUpdate, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.obnovit_gruppu(group_id, group_data) + response = GroupResponse.model_validate(group) + response.students_count = len(group.students) + + return response + + +@router.delete("/{group_id}", response_model=MessageResponse) +def udalit_gruppu( + group_id: int, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.poluchit_gruppu(group_id) + group_name = group.name + service.udalit_gruppu(group_id) + return MessageResponse(message=f"Группа {group_name} успешно удалена") \ No newline at end of file diff --git a/app/api/routes/student.py b/app/api/routes/student.py new file mode 100644 index 0000000..a8baa36 --- /dev/null +++ b/app/api/routes/student.py @@ -0,0 +1,115 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlmodel import Session +from typing import List + +from database import get_session +from services.student import StudentService +from schemas.student import StudentCreate, StudentUpdate, StudentResponse, StudentWithGroup +from schemas.common import MessageResponse + +router = APIRouter(prefix="/students", tags=["Students"]) + +@router.post("/", response_model=StudentResponse, status_code=status.HTTP_201_CREATED) +def sozdat_studenta( + student_data: StudentCreate, + session: Session = Depends(get_session) +): + #эндпоинт создания нового студента + service = StudentService(session) + return service.sozdat_studenta(student_data) + +@router.get("/{student_id}", response_model=StudentWithGroup) +def poluchit_studenta( + student_id: int, + session: Session = Depends(get_session) +): + #эндпоинт получения студента по id + service = StudentService(session) + student = service.poluchit_studenta(student_id) + + #добавим название группы в ответ + response = StudentWithGroup.model_validate(student) + if student.group: + response.group_name = student.group.name + + return response + +@router.get("/", response_model=List[StudentResponse]) +def poluchit_vseh_studentov( + skip: int = Query(0, ge=0, description="Количество пропускаемых от начала спика записей"), + limit: int = Query(100, ge=1, le=100, description="Максимальное количество записей"), + session: Session = Depends(get_session) +): + #эндпоинт получения всех студентов + service = StudentService(session) + return service.poluchit_vseh_studentov(skip, limit) + +@router.put("/{student_id}", response_model=StudentResponse) +def obnovit_studenta( + student_id: int, + student_data: StudentUpdate, + session: Session = Depends(get_session) +): + # эндпоинт обновления данных студента + service = StudentService(session) + return service.obnovit_studenta(student_id, student_data) + +@router.delete("/{student_id}", response_model=MessageResponse) +def udalit_studenta( + student_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + + service.udalit_studenta(student_id) + return MessageResponse(message=f"Студент {name} успешно удалён", success=True) + +@router.post("/{student_id}/groups/{group_id}", response_model=MessageResponse) +def dobavit_studenta_v_gruppu( + student_id: int, + group_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + service.dobavit_studenta_v_gruppu(student_id, group_id) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + + return MessageResponse(message=f"Студент {name} успешно добавлен в группу", success=True) + +@router.delete("/{student_id}/groups", response_model=MessageResponse) +def udalit_studenta_iz_gruppy( + student_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + service.udalit_studenta_iz_gruppy(student_id) + + return MessageResponse(message=f"Студент {name} успешно удален из группы", success=True) + +@router.post("/{student_id}/transfer", response_model=StudentResponse) +def perevesti_studenta( + student_id: int, + to_group_id: int = Query(..., description="Идентификатор целевой группы"), + session: Session = Depends(get_session) +): + service = StudentService(session) + return service.perevesti_studenta(student_id, to_group_id) + +@router.get("/groups/{group_id}/students", response_model=List[StudentResponse]) +def poluchit_studentov_v_gruppe( + group_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + return service.poluchit_studentov_gruppy(group_id) \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..217669d --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + #настройки подключения к базе данных + DB_HOST: str = "db" + DB_PORT: int = 5432 + DB_USER: str = "postgres" + DB_PASSWORD: str = "postgres" + DB_NAME: str = "shefler_students_db" #моя база данных + + # настройки приложения + APP_NAME: str = "SheflerStudentsAPI" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + + @property + def DATABASE_URL(self) -> str: + return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/exception.py b/app/core/exception.py new file mode 100644 index 0000000..1fbaf07 --- /dev/null +++ b/app/core/exception.py @@ -0,0 +1,53 @@ +from fastapi import HTTPException, status + +class StudentNotFoundError(HTTPException): + #Ошибка: студент не найден + def __init__(self, student_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Студент с id {student_id} не найден" + ) + +class GroupNotFoundError(HTTPException): + #Ошибка: группа не найдена + def __init__(self, group_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Группа с id {group_id} не найдена" + ) + +class StudentAlreadyInGroupError(HTTPException): + #Ошибка: студент уже состоит в группе + def __init__(self, student_id: int, group_id: int): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Студент уже состоит в группе, вы можете перевести его в другую" + ) + +class StudentNotInGroupError(HTTPException): + #Ошибка: студент не состоит в группе + def __init__(self, student_id: int, group_id: int = 0, is_transfer: bool = False): + if is_transfer: + detail = "Студент пока не состоит ни в одной группе, сначала добавьте его в группу" + else: + detail = "Данный студент не состоит в группе" + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + +class EmailAlreadyExistsError(HTTPException): + #Ошибка: email уже используется другим студентом + def __init__(self, email: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Студент с email {email} уже существует" + ) + +class GroupNameAlreadyExistsError(HTTPException): + #Ошибка: название группы уже существует + def __init__(self, name: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Группа с названием {name} уже существует" + ) \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2310938 --- /dev/null +++ b/app/database.py @@ -0,0 +1,20 @@ +from sqlmodel import SQLModel, create_engine, Session +from config import settings +from typing import Generator + +#движокдля подключения к базе данных +engine = create_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, #если режим отладки, то выводим запросы в консоль + pool_size=15, #открываем 15 соединений с бд + max_overflow=15 # при нехватке можем добавить еще 15 соединений +) + +def create_db_and_tables(): + #создаем таблицы в базе данных + SQLModel.metadata.create_all(engine) + +def get_session() -> Generator[Session, None, None]: + #получаем сессию для работы с базой данных + with Session(engine) as session: + yield session \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..321df19 --- /dev/null +++ b/app/main.py @@ -0,0 +1,85 @@ +from fastapi import FastAPI, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager + +from config import settings +from database import create_db_and_tables +from api.routes import students, groups +from core.exception import ( + StudentNotFoundError, + GroupNotFoundError, + EmailAlreadyExistsError, + GroupNameAlreadyExistsError, + StudentAlreadyInGroupError, + StudentNotInGroupError +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + #обрабатываем запуск и остановку приложения + # Действия при запуске + create_db_and_tables() + yield + + +#создание экземпляра приложения FastAPI +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + debug=settings.DEBUG, + description="API для управления студентами и группами. Автор: Шефлер ВА", + lifespan=lifespan +) + +#подключение маршрутов +app.include_router(students) +app.include_router(groups) + + +# глобальные обработчики ошибок +@app.exception_handler(StudentNotFoundError) +@app.exception_handler(GroupNotFoundError) +async def not_found_handler(request, exc): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail, "success": False} + ) + + +@app.exception_handler(EmailAlreadyExistsError) +@app.exception_handler(GroupNameAlreadyExistsError) +@app.exception_handler(StudentAlreadyInGroupError) +@app.exception_handler(StudentNotInGroupError) +async def bad_request_handler(request, exc): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail, "success": False} + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": exc.errors(), "success": False} + ) + + +@app.get("/") +async def root(): + #корневой эндпоинт: привествие + return { + "message": f"Добро пожаловать в {settings.APP_NAME}", + "author": "Шефлер Валерия Александровна", + "version": settings.APP_VERSION, + "documentation": "/docs", + "alternative_docs": "/redoc" + } + + +@app.get("/health") +async def health_check(): + #эндпоинт проверки работоспособности + return {"status": "healthy", "success": True} \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..b666893 --- /dev/null +++ b/app/models/group.py @@ -0,0 +1,20 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime + +if TYPE_CHECKING: + from models.student import Student + +class Group(SQLModel, table=True): + #модель группы для базы данных + __tablename__ = "groups" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(unique=True, max_length=50) + #описание группы + description: Optional[str] = Field(default=None, max_length=400) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + #установление связи со студентами типа один ко многим + students: List["Student"] = Relationship(back_populates="group") \ No newline at end of file diff --git a/app/models/student.py b/app/models/student.py new file mode 100644 index 0000000..25905d3 --- /dev/null +++ b/app/models/student.py @@ -0,0 +1,21 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, TYPE_CHECKING +from datetime import datetime + +if TYPE_CHECKING: + from models.group import Group + +class Student(SQLModel, table=True): + #модель студента для базы данных + __tablename__ = "students" + + id: Optional[int] = Field(default=None, primary_key=True) + first_name: str = Field(max_length=30) + last_name: str = Field(max_length=30) + middle_name: Optional[str] = Field(default=None, max_length=30) + email: str = Field(unique=True, max_length=50) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + group_id: Optional[int] = Field(default=None, foreign_key="groups.id") + #дополнительно добавим связь с группой для удобства + group: Optional["Group"] = Relationship(back_populates="students") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..2dae22c --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,32 @@ +#все валидационные схемы + +from schemas.student import ( + StudentCreate, + StudentUpdate, + StudentResponse, + StudentWithGroup +) +from schemas.group import ( + GroupCreate, + GroupUpdate, + GroupResponse, + GroupWithStudents +) +from schemas.common import MessageResponse + +__all__ = [ + # студенты + "StudentBase", + "StudentCreate", + "StudentUpdate", + "StudentResponse", + "StudentWithGroup", + #группы + "GroupBase", + "GroupCreate", + "GroupUpdate", + "GroupResponse", + "GroupWithStudents", + # общее + "MessageResponse" +] \ No newline at end of file diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..1067ac1 --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class MessageResponse(BaseModel): + #базовый ответ с сообщением + message: str + success: bool = True \ No newline at end of file diff --git a/app/schemas/group.py b/app/schemas/group.py new file mode 100644 index 0000000..07473f0 --- /dev/null +++ b/app/schemas/group.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from schemas.student import StudentResponse + +class GroupBase(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + +class GroupCreate(GroupBase): + pass + +class GroupUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + +class GroupResponse(GroupBase): + id: int + created_at: datetime + updated_at: datetime + students_count: Optional[int] = 0 # Количество студентов в группе + + class Config: + from_attributes = True + +class GroupWithStudents(GroupResponse): + #схема группы со списком студентов, остальное аналогично schemas/student.py + students: List[StudentResponse] = [] \ No newline at end of file diff --git a/app/schemas/student.py b/app/schemas/student.py new file mode 100644 index 0000000..9b79f9f --- /dev/null +++ b/app/schemas/student.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + +class StudentBase(BaseModel): + #схема самого студента + first_name: str = Field(min_length=1, max_length=30) + last_name: str = Field(min_length=1, max_length=30) + middle_name: Optional[str] = Field(None, min_length=1, max_length=30) + email: EmailStr + +class StudentCreate(StudentBase): + #схема создания студента + pass + +class StudentUpdate(BaseModel): + #схема обноваления данных + first_name: Optional[str] = Field(None, min_length=1, max_length=30) + last_name: Optional[str] = Field(None, min_length=1, max_length=30) + middle_name: Optional[str] = Field(None, min_length=1, max_length=30) + email: Optional[EmailStr] = None + +class StudentResponse(StudentBase): + # схема ответа на запрос + id: int + group_id: Optional[int] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class StudentWithGroup(StudentResponse): + #схема с инфорцийе о группе + group_name: Optional[str] = None \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/group.py b/app/services/group.py new file mode 100644 index 0000000..76ba52f --- /dev/null +++ b/app/services/group.py @@ -0,0 +1,75 @@ +from sqlmodel import Session, select +from typing import List, Optional +from models.group import Group +from schemas.group import GroupCreate, GroupUpdate +from core.exception import GroupNotFoundError, GroupNameAlreadyExistsError + +#здесь все аналогично api/services/student +class GroupService: + + def __init__(self, session: Session): + self.session = session + + def sozdat_gruppu(self, group_data: GroupCreate) -> Group: + #проверяем не занято ли название группы + sushestvuyushaya_gruppa = self.session.exec( + select(Group).where(Group.name == group_data.name) + ).first() + + if sushestvuyushaya_gruppa: + raise GroupNameAlreadyExistsError(group_data.name) + + group = Group(**group_data.model_dump()) + self.session.add(group) + self.session.commit() + self.session.refresh(group) + return group + + def poluchit_gruppu(self, group_id: int) -> Group: + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + return group + + def poluchit_vse_gruppy(self, skip: int = 0, limit: int = 100) -> List[Group]: + gruppy = self.session.exec( + select(Group).offset(skip).limit(limit) + ).all() + return gruppy + + def obnovit_gruppu(self, group_id: int, group_data: GroupUpdate) -> Group: + group = self.poluchit_gruppu(group_id) + + # рри смене названия группы проверяем уникальность + if group_data.name and group_data.name != group.name: + sushestvuyushaya = self.session.exec( + select(Group).where(Group.name == group_data.name) + ).first() + if sushestvuyushaya: + raise GroupNameAlreadyExistsError(group_data.name) + + update_data = group_data.model_dump(exclude_unset=True) + for pole, znachenie in update_data.items(): + setattr(group, pole, znachenie) + + self.session.add(group) + self.session.commit() + self.session.refresh(group) + return group + + def udalit_gruppu(self, group_id: int) -> None: + #при удалении группы студенты остаются, но без группы + group = self.poluchit_gruppu(group_id) + + # отвязываем студентов от удаляемой группы + for student in group.students: + student.group_id = None + self.session.add(student) + + self.session.delete(group) + self.session.commit() + + def poluchit_gruppu_so_studentami(self, group_id: int) -> Group: + group = self.poluchit_gruppu(group_id) + self.session.refresh(group, attribute_names=["students"]) + return group \ No newline at end of file diff --git a/app/services/student.py b/app/services/student.py new file mode 100644 index 0000000..8879455 --- /dev/null +++ b/app/services/student.py @@ -0,0 +1,149 @@ +from sqlmodel import Session, select +from typing import List, Optional +from models.student import Student +from models.group import Group +from schemas.student import StudentCreate, StudentUpdate +from core.exception import ( + StudentNotFoundError, + EmailAlreadyExistsError, + GroupNotFoundError, + StudentAlreadyInGroupError, + StudentNotInGroupError +) + + +class StudentService: + #сервис работы со студентами + def __init__(self, session: Session): + self.session = session + + def sozdat_studenta(self, student_data: StudentCreate) -> Student: + #cсоздание нового стужента с проверкой того, не занят ли уже email + sushestvuyushiy_student = self.session.exec( + select(Student).where(Student.email == student_data.email) + ).first() + + if sushestvuyushiy_student: + raise EmailAlreadyExistsError(student_data.email) + + #создаем запись студента + student = Student(**student_data.model_dump()) + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def poluchit_studenta(self, student_id: int) -> Student: + #получение информации о студенте по id + student = self.session.get(Student, student_id) + if not student: + raise StudentNotFoundError(student_id) + return student + + def poluchit_vseh_studentov(self, skip: int = 0, limit: int = 100) -> List[Student]: + #получаем список студентов с пограничной группировкой + studenty = self.session.exec( + select(Student).offset(skip).limit(limit) + ).all() + return studenty + + def obnovit_studenta(self, student_id: int, student_data: StudentUpdate) -> Student: + #обновление данных + student = self.poluchit_studenta(student_id) + + # если сменяется почта надо снова проверить уникальность + if student_data.email and student_data.email != student.email: + sushestvuyushiy = self.session.exec( + select(Student).where(Student.email == student_data.email) + ).first() + if sushestvuyushiy: + raise EmailAlreadyExistsError(student_data.email) + + #обновление полей + update_data = student_data.model_dump(exclude_unset=True) + for pole, znachenie in update_data.items(): + setattr(student, pole, znachenie) + + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def udalit_studenta(self, student_id: int) -> None: + #удаление студента + student = self.poluchit_studenta(student_id) + self.session.delete(student) + self.session.commit() + + def dobavit_studenta_v_gruppu(self, student_id: int, group_id: int) -> Student: + #добавление студента в группу + student = self.poluchit_studenta(student_id) + + # Проверяем, существует ли группа + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + + if student.group_id is not None: + current_group = self.session.get(Group, student.group_id) + raise StudentAlreadyInGroupError(student_id, student.group_id) + + student.group_id = group_id + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def udalit_studenta_iz_gruppy(self, student_id: int) -> Student: + #удаление студента из гурппы + student = self.poluchit_studenta(student_id) + + if not student.group_id: + raise StudentNotInGroupError(student_id, 0) + + student.group_id = None + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def perevesti_studenta(self, student_id: int, to_group_id: int) -> Student: + #превод студента из группы в группу + student = self.poluchit_studenta(student_id) + + if not student.group_id: + raise StudentNotInGroupError(student_id, 0, is_transfer=True) + + #проверяем, не пытаемся ли перевести в ту же группу + if student.group_id == to_group_id: + raise StudentAlreadyInGroupError(student_id, to_group_id) + + # проверяем существование целевой группы + to_group = self.session.get(Group, to_group_id) + if not to_group: + raise GroupNotFoundError(to_group_id) + + from_group_id = student.group_id + from_group = self.session.get(Group, from_group_id) + + student.group_id = to_group_id + self.session.add(student) + self.session.commit() + self.session.refresh(student) + + #сохраняем названия групп для сообщения + student._from_group_name = from_group.name if from_group else "без группы" + student._to_group_name = to_group.name + + return student + + def poluchit_studentov_gruppy(self, group_id: int) -> List[Student]: + #получение всех студентов конкретной группы + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + + studenty = self.session.exec( + select(Student).where(Student.group_id == group_id) + ).all() + return studenty \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14e23a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:15-alpine + container_name: shefler_students_db + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-shefler_students_db} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: + - .env + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + container_name: shefler_students_api + ports: + - "${APP_PORT:-8000}:8000" + depends_on: + db: + condition: service_healthy + env_file: + - .env + volumes: + - ./app:/app + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + postgres_data: \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..086befd --- /dev/null +++ b/readme.md @@ -0,0 +1,82 @@ +# SheflerStudentsAPI + +API для управления студентами и группами. + +Автор: Шефлер Валерия Александровна + +## Функциональность + +- Создание, чтение, обновление, удаление студентов +- Создание, чтение, обновление, удаление групп +- Добавление студентов в группы +- Удаление студентов из групп +- Перевод студентов между группами +- Получение списка студентов в группе + +## Технологии + +- FastAPI +- SQLModel +- PostgreSQL +- Docker + +Для запуска приложения необходимо установить docker + +Инструкция по запуску приложения: + +1. Клонировать репозиторий https://github.com/ValeriiaShefler/FastAPI_task_3_Shefler себе на компьютер +2. В папке с проектом найдите файл .env.example и скопируйте его содержимое в файл .env +3. Откройте терминал и перейдите в папку с проектом, например, просто перетащив папку проекта в окно терминала +4. в командрой строке выполните: + docker-compose up --build +5. Ожидайте пока не увидите строку: Application startup complete +(ВАЖНО: не закрывайте окно терминала) +6. Откройте бразуер и перейдите по адресу http://localhost:8000 +Если все работает, то вы увидите: + {"message":"Добро пожаловать в SheflerStudentsAPI","author":"Шефлер Валерия Александровна","version":"1.0.0","documentation":"/docs","alternative_docs":"/redoc"} + +Инструкция по использованию приложения: + +1. В браузере перейдите по ссылке http://localhost:8000/docs, где вы увидите интерактивную страницу со всеми доступными командами +2. Пример выполнения команды создания студента: + 2.1. Перейти в раздел Students и выбрать команду POST /students + 2.2. Нажать внопку "Try it out" + 2.3. Заполнить данные в поле Request body (отчество опционально): + { + "first_name": "Валерия", + "last_name": "Шефлер", + "middle_name": "Александровна", + "email": "lera@example.ru" + } + 2.4. Нажать кнопку Execute для выполнения + 2.5. Посмотреть результат (например): + { + "id": 1, + "first_name": "Валерия", + "last_name": "Шефлер", + "middle_name": "Александровна", + "email": "lera@example.ru", + "created_at": "2026-05-06T10:29:01", + "updated_at": "2026-05-06T10:29:01", + "group_id": null + } +3. Список команд: + - Создать студента: POST /students с указанием имени, фамилии и почты + - Создать группу: POST /groups с указанием навания и описания группы + - Посмотреть вообще всех студентов: GET /students с указанием того, какое количество записей вы хотите увидеть и начиная с какой записи + - Посмотреть вообще все группы: GET /groups с указанием того, какое количество записей вы хотите увидеть и начиная с какой записи + - Найти студента по id: GET /students/{id} с указанием id студента + - Найти группу по id: GET /groups/{id} с указанием id группы + - Изменить данные студента: PUT /students/{id} с указанием id студента и самих данных + - Изменить данные группы: PUT /groups/{id} с указанием id группы и самих данных + - Удалить студента: DELETE /students/{id} с указанием id студента + - Удалить группу: DELETE /groups/{id} с указанием id группы + - Добавить студента в группу: POST /students/{student_id}/groups/{group_id} с указанием id студента и группы + - Удалить студента из группы: DELETE /students/{student_id}/groups с указанием id студента + - Перевести в другую группу: POST /students/{student_id}/transfer с указанием id студента, а также в какую группу перевести + - Просмотреть всех студентов в группе: GET /students/groups/{group_id}/students с указанием id группы + +4. Остановить приложение можно через команду docker-compose down, данные при этом сохраняются +5. Остановить с удалением данных можно через docker-compose down -v +5. Для удаления приложения воспользуйтесь docker-compose down -v --rmi all + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5cde1ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlmodel==0.0.14 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +alembic==1.12.1 +pydantic-settings==2.1.0 +email-validator==2.1.0 \ No newline at end of file From 3601075325209b937b7fb7846e89fbfeab170208 Mon Sep 17 00:00:00 2001 From: Shefler Date: Thu, 7 May 2026 03:51:04 +0300 Subject: [PATCH 2/3] Add Students API implementation --- .env.example | 11 +++ .gitignore | 37 +++++++++ Dockerfile | 15 ++++ app/__init__.py | 0 app/api/__init__.py | 0 app/api/dependencies.py | 0 app/api/routes/__init__.py | 2 + app/api/routes/group.py | 76 +++++++++++++++++++ app/api/routes/student.py | 115 ++++++++++++++++++++++++++++ app/config.py | 27 +++++++ app/core/__init__.py | 0 app/core/exception.py | 53 +++++++++++++ app/database.py | 20 +++++ app/main.py | 85 +++++++++++++++++++++ app/models/__init__.py | 0 app/models/group.py | 20 +++++ app/models/student.py | 21 ++++++ app/schemas/__init__.py | 32 ++++++++ app/schemas/common.py | 6 ++ app/schemas/group.py | 28 +++++++ app/schemas/student.py | 35 +++++++++ app/services/__init__.py | 0 app/services/group.py | 75 +++++++++++++++++++ app/services/student.py | 149 +++++++++++++++++++++++++++++++++++++ docker-compose.yml | 36 +++++++++ readme.md | 82 ++++++++++++++++++++ requirements.txt | 8 ++ 27 files changed, 933 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/dependencies.py create mode 100644 app/api/routes/__init__.py create mode 100644 app/api/routes/group.py create mode 100644 app/api/routes/student.py create mode 100644 app/config.py create mode 100644 app/core/__init__.py create mode 100644 app/core/exception.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/group.py create mode 100644 app/models/student.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/common.py create mode 100644 app/schemas/group.py create mode 100644 app/schemas/student.py create mode 100644 app/services/__init__.py create mode 100644 app/services/group.py create mode 100644 app/services/student.py create mode 100644 docker-compose.yml create mode 100644 readme.md create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd04d4b --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +#настройки базы данных +DB_HOST=localhost +DB_PORT=5432 +DB_USER=postgres +DB_PASSWORD=postgres +DB_NAME=shefler_students_db + +# настройки приложения +APP_NAME=SheflerStudentsAPI +APP_VERSION=1.0.0 +DEBUG=True \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66d0439 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# среда разарботки +.vscode/ +.idea/ +*.swp +*.swo + +#среда +.env +.env.local + +# docker +*.log + +# база данных +*.db +*.sqlite + +# тестирование +.pytest_cache/ +.coverage +htmlcov/ + +#OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2dc29c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim +WORKDIR /app + +#установка системных зависимостей +RUN apt-get update && apt-get install -y gcc && rm -rf /var/lib/apt/lists/* + +# копирование и установка Python зависимостей +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +#копирование кода приложения +COPY ./app /app + +# запуск приложения +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/dependencies.py b/app/api/dependencies.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..fa5a38e --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,2 @@ +from api.routes.student import router as students +from api.routes.group import router as groups \ No newline at end of file diff --git a/app/api/routes/group.py b/app/api/routes/group.py new file mode 100644 index 0000000..221c75f --- /dev/null +++ b/app/api/routes/group.py @@ -0,0 +1,76 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlmodel import Session +from typing import List + +from database import get_session +from services.group import GroupService +from schemas.group import GroupCreate, GroupUpdate, GroupResponse, GroupWithStudents +from schemas.common import MessageResponse + +router = APIRouter(prefix="/groups", tags=["Groups"]) + +@router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED) +def sozdat_gruppu( + group_data: GroupCreate, + session: Session = Depends(get_session) +): + service = GroupService(session) + return service.sozdat_gruppu(group_data) + + +@router.get("/{group_id}", response_model=GroupWithStudents) +def poluchit_gruppu_po_id( + group_id: int, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.poluchit_gruppu_so_studentami(group_id) + + response = GroupWithStudents.model_validate(group) + response.students_count = len(group.students) + + return response + + +@router.get("/", response_model=List[GroupResponse]) +def poluchit_vse_gruppy( + skip: int = Query(0, ge=0, description="Количество пропускаемых с начала записей"), + limit: int = Query(100, ge=1, le=100, description="Максимальное количество записей"), + session: Session = Depends(get_session) +): + service = GroupService(session) + gruppy = service.poluchit_vse_gruppy(skip, limit) + + result = [] + for group in gruppy: + group_dict = group.model_dump() + group_dict['students_count'] = len(group.students) + result.append(GroupResponse(**group_dict)) + + return result + + +@router.put("/{group_id}", response_model=GroupResponse) +def obnovit_gruppu( + group_id: int, + group_data: GroupUpdate, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.obnovit_gruppu(group_id, group_data) + response = GroupResponse.model_validate(group) + response.students_count = len(group.students) + + return response + + +@router.delete("/{group_id}", response_model=MessageResponse) +def udalit_gruppu( + group_id: int, + session: Session = Depends(get_session) +): + service = GroupService(session) + group = service.poluchit_gruppu(group_id) + group_name = group.name + service.udalit_gruppu(group_id) + return MessageResponse(message=f"Группа {group_name} успешно удалена") \ No newline at end of file diff --git a/app/api/routes/student.py b/app/api/routes/student.py new file mode 100644 index 0000000..a8baa36 --- /dev/null +++ b/app/api/routes/student.py @@ -0,0 +1,115 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlmodel import Session +from typing import List + +from database import get_session +from services.student import StudentService +from schemas.student import StudentCreate, StudentUpdate, StudentResponse, StudentWithGroup +from schemas.common import MessageResponse + +router = APIRouter(prefix="/students", tags=["Students"]) + +@router.post("/", response_model=StudentResponse, status_code=status.HTTP_201_CREATED) +def sozdat_studenta( + student_data: StudentCreate, + session: Session = Depends(get_session) +): + #эндпоинт создания нового студента + service = StudentService(session) + return service.sozdat_studenta(student_data) + +@router.get("/{student_id}", response_model=StudentWithGroup) +def poluchit_studenta( + student_id: int, + session: Session = Depends(get_session) +): + #эндпоинт получения студента по id + service = StudentService(session) + student = service.poluchit_studenta(student_id) + + #добавим название группы в ответ + response = StudentWithGroup.model_validate(student) + if student.group: + response.group_name = student.group.name + + return response + +@router.get("/", response_model=List[StudentResponse]) +def poluchit_vseh_studentov( + skip: int = Query(0, ge=0, description="Количество пропускаемых от начала спика записей"), + limit: int = Query(100, ge=1, le=100, description="Максимальное количество записей"), + session: Session = Depends(get_session) +): + #эндпоинт получения всех студентов + service = StudentService(session) + return service.poluchit_vseh_studentov(skip, limit) + +@router.put("/{student_id}", response_model=StudentResponse) +def obnovit_studenta( + student_id: int, + student_data: StudentUpdate, + session: Session = Depends(get_session) +): + # эндпоинт обновления данных студента + service = StudentService(session) + return service.obnovit_studenta(student_id, student_data) + +@router.delete("/{student_id}", response_model=MessageResponse) +def udalit_studenta( + student_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + + service.udalit_studenta(student_id) + return MessageResponse(message=f"Студент {name} успешно удалён", success=True) + +@router.post("/{student_id}/groups/{group_id}", response_model=MessageResponse) +def dobavit_studenta_v_gruppu( + student_id: int, + group_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + service.dobavit_studenta_v_gruppu(student_id, group_id) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + + return MessageResponse(message=f"Студент {name} успешно добавлен в группу", success=True) + +@router.delete("/{student_id}/groups", response_model=MessageResponse) +def udalit_studenta_iz_gruppy( + student_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + student = service.poluchit_studenta(student_id) + name = f"{student.last_name} {student.first_name}" + if student.middle_name: + name += f" {student.middle_name}" + service.udalit_studenta_iz_gruppy(student_id) + + return MessageResponse(message=f"Студент {name} успешно удален из группы", success=True) + +@router.post("/{student_id}/transfer", response_model=StudentResponse) +def perevesti_studenta( + student_id: int, + to_group_id: int = Query(..., description="Идентификатор целевой группы"), + session: Session = Depends(get_session) +): + service = StudentService(session) + return service.perevesti_studenta(student_id, to_group_id) + +@router.get("/groups/{group_id}/students", response_model=List[StudentResponse]) +def poluchit_studentov_v_gruppe( + group_id: int, + session: Session = Depends(get_session) +): + service = StudentService(session) + return service.poluchit_studentov_gruppy(group_id) \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..217669d --- /dev/null +++ b/app/config.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + #настройки подключения к базе данных + DB_HOST: str = "db" + DB_PORT: int = 5432 + DB_USER: str = "postgres" + DB_PASSWORD: str = "postgres" + DB_NAME: str = "shefler_students_db" #моя база данных + + # настройки приложения + APP_NAME: str = "SheflerStudentsAPI" + APP_VERSION: str = "1.0.0" + DEBUG: bool = False + + @property + def DATABASE_URL(self) -> str: + return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() \ No newline at end of file diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/exception.py b/app/core/exception.py new file mode 100644 index 0000000..1fbaf07 --- /dev/null +++ b/app/core/exception.py @@ -0,0 +1,53 @@ +from fastapi import HTTPException, status + +class StudentNotFoundError(HTTPException): + #Ошибка: студент не найден + def __init__(self, student_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Студент с id {student_id} не найден" + ) + +class GroupNotFoundError(HTTPException): + #Ошибка: группа не найдена + def __init__(self, group_id: int): + super().__init__( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Группа с id {group_id} не найдена" + ) + +class StudentAlreadyInGroupError(HTTPException): + #Ошибка: студент уже состоит в группе + def __init__(self, student_id: int, group_id: int): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Студент уже состоит в группе, вы можете перевести его в другую" + ) + +class StudentNotInGroupError(HTTPException): + #Ошибка: студент не состоит в группе + def __init__(self, student_id: int, group_id: int = 0, is_transfer: bool = False): + if is_transfer: + detail = "Студент пока не состоит ни в одной группе, сначала добавьте его в группу" + else: + detail = "Данный студент не состоит в группе" + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=detail + ) + +class EmailAlreadyExistsError(HTTPException): + #Ошибка: email уже используется другим студентом + def __init__(self, email: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Студент с email {email} уже существует" + ) + +class GroupNameAlreadyExistsError(HTTPException): + #Ошибка: название группы уже существует + def __init__(self, name: str): + super().__init__( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Группа с названием {name} уже существует" + ) \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2310938 --- /dev/null +++ b/app/database.py @@ -0,0 +1,20 @@ +from sqlmodel import SQLModel, create_engine, Session +from config import settings +from typing import Generator + +#движокдля подключения к базе данных +engine = create_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, #если режим отладки, то выводим запросы в консоль + pool_size=15, #открываем 15 соединений с бд + max_overflow=15 # при нехватке можем добавить еще 15 соединений +) + +def create_db_and_tables(): + #создаем таблицы в базе данных + SQLModel.metadata.create_all(engine) + +def get_session() -> Generator[Session, None, None]: + #получаем сессию для работы с базой данных + with Session(engine) as session: + yield session \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..321df19 --- /dev/null +++ b/app/main.py @@ -0,0 +1,85 @@ +from fastapi import FastAPI, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from contextlib import asynccontextmanager + +from config import settings +from database import create_db_and_tables +from api.routes import students, groups +from core.exception import ( + StudentNotFoundError, + GroupNotFoundError, + EmailAlreadyExistsError, + GroupNameAlreadyExistsError, + StudentAlreadyInGroupError, + StudentNotInGroupError +) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + #обрабатываем запуск и остановку приложения + # Действия при запуске + create_db_and_tables() + yield + + +#создание экземпляра приложения FastAPI +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + debug=settings.DEBUG, + description="API для управления студентами и группами. Автор: Шефлер ВА", + lifespan=lifespan +) + +#подключение маршрутов +app.include_router(students) +app.include_router(groups) + + +# глобальные обработчики ошибок +@app.exception_handler(StudentNotFoundError) +@app.exception_handler(GroupNotFoundError) +async def not_found_handler(request, exc): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail, "success": False} + ) + + +@app.exception_handler(EmailAlreadyExistsError) +@app.exception_handler(GroupNameAlreadyExistsError) +@app.exception_handler(StudentAlreadyInGroupError) +@app.exception_handler(StudentNotInGroupError) +async def bad_request_handler(request, exc): + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail, "success": False} + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request, exc): + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={"detail": exc.errors(), "success": False} + ) + + +@app.get("/") +async def root(): + #корневой эндпоинт: привествие + return { + "message": f"Добро пожаловать в {settings.APP_NAME}", + "author": "Шефлер Валерия Александровна", + "version": settings.APP_VERSION, + "documentation": "/docs", + "alternative_docs": "/redoc" + } + + +@app.get("/health") +async def health_check(): + #эндпоинт проверки работоспособности + return {"status": "healthy", "success": True} \ No newline at end of file diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/group.py b/app/models/group.py new file mode 100644 index 0000000..b666893 --- /dev/null +++ b/app/models/group.py @@ -0,0 +1,20 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime + +if TYPE_CHECKING: + from models.student import Student + +class Group(SQLModel, table=True): + #модель группы для базы данных + __tablename__ = "groups" + + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(unique=True, max_length=50) + #описание группы + description: Optional[str] = Field(default=None, max_length=400) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + #установление связи со студентами типа один ко многим + students: List["Student"] = Relationship(back_populates="group") \ No newline at end of file diff --git a/app/models/student.py b/app/models/student.py new file mode 100644 index 0000000..25905d3 --- /dev/null +++ b/app/models/student.py @@ -0,0 +1,21 @@ +from sqlmodel import SQLModel, Field, Relationship +from typing import Optional, TYPE_CHECKING +from datetime import datetime + +if TYPE_CHECKING: + from models.group import Group + +class Student(SQLModel, table=True): + #модель студента для базы данных + __tablename__ = "students" + + id: Optional[int] = Field(default=None, primary_key=True) + first_name: str = Field(max_length=30) + last_name: str = Field(max_length=30) + middle_name: Optional[str] = Field(default=None, max_length=30) + email: str = Field(unique=True, max_length=50) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + group_id: Optional[int] = Field(default=None, foreign_key="groups.id") + #дополнительно добавим связь с группой для удобства + group: Optional["Group"] = Relationship(back_populates="students") \ No newline at end of file diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..2dae22c --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,32 @@ +#все валидационные схемы + +from schemas.student import ( + StudentCreate, + StudentUpdate, + StudentResponse, + StudentWithGroup +) +from schemas.group import ( + GroupCreate, + GroupUpdate, + GroupResponse, + GroupWithStudents +) +from schemas.common import MessageResponse + +__all__ = [ + # студенты + "StudentBase", + "StudentCreate", + "StudentUpdate", + "StudentResponse", + "StudentWithGroup", + #группы + "GroupBase", + "GroupCreate", + "GroupUpdate", + "GroupResponse", + "GroupWithStudents", + # общее + "MessageResponse" +] \ No newline at end of file diff --git a/app/schemas/common.py b/app/schemas/common.py new file mode 100644 index 0000000..1067ac1 --- /dev/null +++ b/app/schemas/common.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + +class MessageResponse(BaseModel): + #базовый ответ с сообщением + message: str + success: bool = True \ No newline at end of file diff --git a/app/schemas/group.py b/app/schemas/group.py new file mode 100644 index 0000000..07473f0 --- /dev/null +++ b/app/schemas/group.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime +from schemas.student import StudentResponse + +class GroupBase(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + +class GroupCreate(GroupBase): + pass + +class GroupUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = Field(None, max_length=500) + +class GroupResponse(GroupBase): + id: int + created_at: datetime + updated_at: datetime + students_count: Optional[int] = 0 # Количество студентов в группе + + class Config: + from_attributes = True + +class GroupWithStudents(GroupResponse): + #схема группы со списком студентов, остальное аналогично schemas/student.py + students: List[StudentResponse] = [] \ No newline at end of file diff --git a/app/schemas/student.py b/app/schemas/student.py new file mode 100644 index 0000000..9b79f9f --- /dev/null +++ b/app/schemas/student.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel, EmailStr, Field +from typing import Optional +from datetime import datetime + +class StudentBase(BaseModel): + #схема самого студента + first_name: str = Field(min_length=1, max_length=30) + last_name: str = Field(min_length=1, max_length=30) + middle_name: Optional[str] = Field(None, min_length=1, max_length=30) + email: EmailStr + +class StudentCreate(StudentBase): + #схема создания студента + pass + +class StudentUpdate(BaseModel): + #схема обноваления данных + first_name: Optional[str] = Field(None, min_length=1, max_length=30) + last_name: Optional[str] = Field(None, min_length=1, max_length=30) + middle_name: Optional[str] = Field(None, min_length=1, max_length=30) + email: Optional[EmailStr] = None + +class StudentResponse(StudentBase): + # схема ответа на запрос + id: int + group_id: Optional[int] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + +class StudentWithGroup(StudentResponse): + #схема с инфорцийе о группе + group_name: Optional[str] = None \ No newline at end of file diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/group.py b/app/services/group.py new file mode 100644 index 0000000..76ba52f --- /dev/null +++ b/app/services/group.py @@ -0,0 +1,75 @@ +from sqlmodel import Session, select +from typing import List, Optional +from models.group import Group +from schemas.group import GroupCreate, GroupUpdate +from core.exception import GroupNotFoundError, GroupNameAlreadyExistsError + +#здесь все аналогично api/services/student +class GroupService: + + def __init__(self, session: Session): + self.session = session + + def sozdat_gruppu(self, group_data: GroupCreate) -> Group: + #проверяем не занято ли название группы + sushestvuyushaya_gruppa = self.session.exec( + select(Group).where(Group.name == group_data.name) + ).first() + + if sushestvuyushaya_gruppa: + raise GroupNameAlreadyExistsError(group_data.name) + + group = Group(**group_data.model_dump()) + self.session.add(group) + self.session.commit() + self.session.refresh(group) + return group + + def poluchit_gruppu(self, group_id: int) -> Group: + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + return group + + def poluchit_vse_gruppy(self, skip: int = 0, limit: int = 100) -> List[Group]: + gruppy = self.session.exec( + select(Group).offset(skip).limit(limit) + ).all() + return gruppy + + def obnovit_gruppu(self, group_id: int, group_data: GroupUpdate) -> Group: + group = self.poluchit_gruppu(group_id) + + # рри смене названия группы проверяем уникальность + if group_data.name and group_data.name != group.name: + sushestvuyushaya = self.session.exec( + select(Group).where(Group.name == group_data.name) + ).first() + if sushestvuyushaya: + raise GroupNameAlreadyExistsError(group_data.name) + + update_data = group_data.model_dump(exclude_unset=True) + for pole, znachenie in update_data.items(): + setattr(group, pole, znachenie) + + self.session.add(group) + self.session.commit() + self.session.refresh(group) + return group + + def udalit_gruppu(self, group_id: int) -> None: + #при удалении группы студенты остаются, но без группы + group = self.poluchit_gruppu(group_id) + + # отвязываем студентов от удаляемой группы + for student in group.students: + student.group_id = None + self.session.add(student) + + self.session.delete(group) + self.session.commit() + + def poluchit_gruppu_so_studentami(self, group_id: int) -> Group: + group = self.poluchit_gruppu(group_id) + self.session.refresh(group, attribute_names=["students"]) + return group \ No newline at end of file diff --git a/app/services/student.py b/app/services/student.py new file mode 100644 index 0000000..8879455 --- /dev/null +++ b/app/services/student.py @@ -0,0 +1,149 @@ +from sqlmodel import Session, select +from typing import List, Optional +from models.student import Student +from models.group import Group +from schemas.student import StudentCreate, StudentUpdate +from core.exception import ( + StudentNotFoundError, + EmailAlreadyExistsError, + GroupNotFoundError, + StudentAlreadyInGroupError, + StudentNotInGroupError +) + + +class StudentService: + #сервис работы со студентами + def __init__(self, session: Session): + self.session = session + + def sozdat_studenta(self, student_data: StudentCreate) -> Student: + #cсоздание нового стужента с проверкой того, не занят ли уже email + sushestvuyushiy_student = self.session.exec( + select(Student).where(Student.email == student_data.email) + ).first() + + if sushestvuyushiy_student: + raise EmailAlreadyExistsError(student_data.email) + + #создаем запись студента + student = Student(**student_data.model_dump()) + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def poluchit_studenta(self, student_id: int) -> Student: + #получение информации о студенте по id + student = self.session.get(Student, student_id) + if not student: + raise StudentNotFoundError(student_id) + return student + + def poluchit_vseh_studentov(self, skip: int = 0, limit: int = 100) -> List[Student]: + #получаем список студентов с пограничной группировкой + studenty = self.session.exec( + select(Student).offset(skip).limit(limit) + ).all() + return studenty + + def obnovit_studenta(self, student_id: int, student_data: StudentUpdate) -> Student: + #обновление данных + student = self.poluchit_studenta(student_id) + + # если сменяется почта надо снова проверить уникальность + if student_data.email and student_data.email != student.email: + sushestvuyushiy = self.session.exec( + select(Student).where(Student.email == student_data.email) + ).first() + if sushestvuyushiy: + raise EmailAlreadyExistsError(student_data.email) + + #обновление полей + update_data = student_data.model_dump(exclude_unset=True) + for pole, znachenie in update_data.items(): + setattr(student, pole, znachenie) + + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def udalit_studenta(self, student_id: int) -> None: + #удаление студента + student = self.poluchit_studenta(student_id) + self.session.delete(student) + self.session.commit() + + def dobavit_studenta_v_gruppu(self, student_id: int, group_id: int) -> Student: + #добавление студента в группу + student = self.poluchit_studenta(student_id) + + # Проверяем, существует ли группа + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + + if student.group_id is not None: + current_group = self.session.get(Group, student.group_id) + raise StudentAlreadyInGroupError(student_id, student.group_id) + + student.group_id = group_id + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def udalit_studenta_iz_gruppy(self, student_id: int) -> Student: + #удаление студента из гурппы + student = self.poluchit_studenta(student_id) + + if not student.group_id: + raise StudentNotInGroupError(student_id, 0) + + student.group_id = None + self.session.add(student) + self.session.commit() + self.session.refresh(student) + return student + + def perevesti_studenta(self, student_id: int, to_group_id: int) -> Student: + #превод студента из группы в группу + student = self.poluchit_studenta(student_id) + + if not student.group_id: + raise StudentNotInGroupError(student_id, 0, is_transfer=True) + + #проверяем, не пытаемся ли перевести в ту же группу + if student.group_id == to_group_id: + raise StudentAlreadyInGroupError(student_id, to_group_id) + + # проверяем существование целевой группы + to_group = self.session.get(Group, to_group_id) + if not to_group: + raise GroupNotFoundError(to_group_id) + + from_group_id = student.group_id + from_group = self.session.get(Group, from_group_id) + + student.group_id = to_group_id + self.session.add(student) + self.session.commit() + self.session.refresh(student) + + #сохраняем названия групп для сообщения + student._from_group_name = from_group.name if from_group else "без группы" + student._to_group_name = to_group.name + + return student + + def poluchit_studentov_gruppy(self, group_id: int) -> List[Student]: + #получение всех студентов конкретной группы + group = self.session.get(Group, group_id) + if not group: + raise GroupNotFoundError(group_id) + + studenty = self.session.exec( + select(Student).where(Student.group_id == group_id) + ).all() + return studenty \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..14e23a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:15-alpine + container_name: shefler_students_db + environment: + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + POSTGRES_DB: ${DB_NAME:-shefler_students_db} + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: + - .env + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + + app: + build: . + container_name: shefler_students_api + ports: + - "${APP_PORT:-8000}:8000" + depends_on: + db: + condition: service_healthy + env_file: + - .env + volumes: + - ./app:/app + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + +volumes: + postgres_data: \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..086befd --- /dev/null +++ b/readme.md @@ -0,0 +1,82 @@ +# SheflerStudentsAPI + +API для управления студентами и группами. + +Автор: Шефлер Валерия Александровна + +## Функциональность + +- Создание, чтение, обновление, удаление студентов +- Создание, чтение, обновление, удаление групп +- Добавление студентов в группы +- Удаление студентов из групп +- Перевод студентов между группами +- Получение списка студентов в группе + +## Технологии + +- FastAPI +- SQLModel +- PostgreSQL +- Docker + +Для запуска приложения необходимо установить docker + +Инструкция по запуску приложения: + +1. Клонировать репозиторий https://github.com/ValeriiaShefler/FastAPI_task_3_Shefler себе на компьютер +2. В папке с проектом найдите файл .env.example и скопируйте его содержимое в файл .env +3. Откройте терминал и перейдите в папку с проектом, например, просто перетащив папку проекта в окно терминала +4. в командрой строке выполните: + docker-compose up --build +5. Ожидайте пока не увидите строку: Application startup complete +(ВАЖНО: не закрывайте окно терминала) +6. Откройте бразуер и перейдите по адресу http://localhost:8000 +Если все работает, то вы увидите: + {"message":"Добро пожаловать в SheflerStudentsAPI","author":"Шефлер Валерия Александровна","version":"1.0.0","documentation":"/docs","alternative_docs":"/redoc"} + +Инструкция по использованию приложения: + +1. В браузере перейдите по ссылке http://localhost:8000/docs, где вы увидите интерактивную страницу со всеми доступными командами +2. Пример выполнения команды создания студента: + 2.1. Перейти в раздел Students и выбрать команду POST /students + 2.2. Нажать внопку "Try it out" + 2.3. Заполнить данные в поле Request body (отчество опционально): + { + "first_name": "Валерия", + "last_name": "Шефлер", + "middle_name": "Александровна", + "email": "lera@example.ru" + } + 2.4. Нажать кнопку Execute для выполнения + 2.5. Посмотреть результат (например): + { + "id": 1, + "first_name": "Валерия", + "last_name": "Шефлер", + "middle_name": "Александровна", + "email": "lera@example.ru", + "created_at": "2026-05-06T10:29:01", + "updated_at": "2026-05-06T10:29:01", + "group_id": null + } +3. Список команд: + - Создать студента: POST /students с указанием имени, фамилии и почты + - Создать группу: POST /groups с указанием навания и описания группы + - Посмотреть вообще всех студентов: GET /students с указанием того, какое количество записей вы хотите увидеть и начиная с какой записи + - Посмотреть вообще все группы: GET /groups с указанием того, какое количество записей вы хотите увидеть и начиная с какой записи + - Найти студента по id: GET /students/{id} с указанием id студента + - Найти группу по id: GET /groups/{id} с указанием id группы + - Изменить данные студента: PUT /students/{id} с указанием id студента и самих данных + - Изменить данные группы: PUT /groups/{id} с указанием id группы и самих данных + - Удалить студента: DELETE /students/{id} с указанием id студента + - Удалить группу: DELETE /groups/{id} с указанием id группы + - Добавить студента в группу: POST /students/{student_id}/groups/{group_id} с указанием id студента и группы + - Удалить студента из группы: DELETE /students/{student_id}/groups с указанием id студента + - Перевести в другую группу: POST /students/{student_id}/transfer с указанием id студента, а также в какую группу перевести + - Просмотреть всех студентов в группе: GET /students/groups/{group_id}/students с указанием id группы + +4. Остановить приложение можно через команду docker-compose down, данные при этом сохраняются +5. Остановить с удалением данных можно через docker-compose down -v +5. Для удаления приложения воспользуйтесь docker-compose down -v --rmi all + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5cde1ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlmodel==0.0.14 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +alembic==1.12.1 +pydantic-settings==2.1.0 +email-validator==2.1.0 \ No newline at end of file From 4a0c75883867de4e4ed58313971056315c5c1336 Mon Sep 17 00:00:00 2001 From: Shefler Date: Thu, 7 May 2026 07:35:37 +0300 Subject: [PATCH 3/3] fix: change DB_HOST from localhost to db in .env.example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index fd04d4b..d93539a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ #настройки базы данных -DB_HOST=localhost +DB_HOST=db DB_PORT=5432 DB_USER=postgres DB_PASSWORD=postgres