🏠 Домашнее задание 6: Категории вопросов

📋 Python Advanced: ДЗ 6 из LMS 🎯 Расширение API Community Pulse

⚡ ДЗ 6 кратко

Цель: Расширить API — добавить поддержку категорий вопросов (Category).

  • Схема CategoryBase в Pydantic
  • CRUD эндпоинты: POST/GET/PUT/DELETE /categories
  • Обновить QuestionCreate и QuestionResponse — добавить category_id
  • GET /questions возвращает вопросы с информацией о категории
  • POST /questions принимает category

Задание из LMS (оригинал)

Цели задания: Расширить функциональность существующего API для поддержки категорий вопросов.

Задачи

  1. Обновление схем Pydantic
    • Добавьте новую схему CategoryBase в schemas/question.py для сериализации и валидации данных категории.
    • Обновите схему QuestionCreate и QuestionResponse для интеграции данных о категории.
  2. Разработка API эндпоинтов
    • Создайте новые эндпоинты для создания, чтения, обновления и удаления категорий.
    • POST /categories — создание новой категории.
    • GET /categories — получение списка всех категорий.
    • PUT /categories/{id} — обновление категории по ID.
    • DELETE /categories/{id} — удаление категории по ID.
  3. Обновите существующие эндпоинты вопросов
    • GET /questions должен возвращать вопросы с информацией о категориях.
    • POST /questions должен позволять указывать категорию при создании вопроса.

Подготовка окружения

1. Активировать виртуальное окружение

# Windows PowerShell
cd community_pulse
.venv\Scripts\activate

# Linux/macOS
source .venv/bin/activate

2. Проверить зависимости

pip install flask flask-sqlalchemy sqlalchemy pydantic flask-migrate

3. Структура проекта

community_pulse/
├── app/
│   ├── __init__.py          # create_app()
│   ├── models/
│   │   ├── __init__.py
│   │   ├── questions.py     # Question model
│   │   ├── category.py      # NEW: Category model
│   │   ├── response.py      # Response model
│   │   └── statistic.py     # Statistic model
│   ├── routers/
│   │   ├── questions.py     # UPDATE: поддержка category
│   │   ├── response.py      # без изменений
│   │   └── categories.py    # NEW: CRUD для Category
│   └── schemas/
│       └── question.py      # UPDATE: CategoryBase, QuestionCreate, QuestionResponse
├── migrations/
├── .env
└── run.py

4. Git — зафиксировать начальное состояние

git add -A
git commit -m "chore: start homework 6 — add category support"

Пошаговое решение

Шаг 1: Модель Category

Создать app/models/category.py. Категория имеет название и опциональное описание.

# app/models/category.py
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Text
from app.models import db


class Category(db.Model):
    __tablename__ = 'categories'

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
    description: Mapped[str | None] = mapped_column(Text, nullable=True)

    # Обратная связь с Question
    questions: Mapped[list['Question']] = relationship(
        'Question', back_populates='category'
    )

    def __repr__(self) -> str:
        return f'<Category {self.name}>'

Логика: Mapped[str | None] — nullable поле (SQLAlchemy 2.x). unique=True предотвращает дубликаты названий.

Шаг 2: Обновить модель Question

Добавить внешний ключ category_id и связь с Category.

# app/models/questions.py
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, ForeignKey
from app.models import db


class Question(db.Model):
    __tablename__ = 'questions'

    id: Mapped[int] = mapped_column(primary_key=True)
    text: Mapped[str] = mapped_column(String(500), nullable=False)
    category_id: Mapped[int | None] = mapped_column(
        ForeignKey('categories.id'), nullable=True
    )

    # Связь с Category
    category: Mapped['Category | None'] = relationship(
        'Category', back_populates='questions'
    )

    def __repr__(self) -> str:
        return f'<Question {self.id}: {self.text[:30]}>'

Логика: nullable=True — вопрос может быть без категории (опциональная связь).

Шаг 3: Миграция базы данных

# Первая миграция
flask db init          # если ещё не инициализировано

# Генерация миграции
flask db migrate -m "add category model and foreign key to question"

# Применить миграцию
flask db upgrade

Проверить: в migrations/versions/ появился новый файл. Открыть и убедиться, что создаётся таблица categories и добавляется колонка category_id в questions.

Шаг 4: Pydantic-схемы

Обновить app/schemas/question.py:

# app/schemas/question.py
from pydantic import BaseModel, Field
from typing import Optional


class CategoryBase(BaseModel):
    """Схема для категории."""
    id: int
    name: str
    description: Optional[str] = None

    model_config = {'from_attributes': True}


class CategoryCreate(BaseModel):
    """Схема для создания категории."""
    name: str = Field(..., min_length=1, max_length=100)
    description: Optional[str] = None


class QuestionCreate(BaseModel):
    """Схема для создания вопроса."""
    text: str = Field(..., min_length=1, max_length=500)
    category_id: Optional[int] = None


class QuestionResponse(BaseModel):
    """Схема ответа при возврате вопроса."""
    id: int
    text: str
    category: Optional[CategoryBase] = None

    model_config = {'from_attributes': True}

Логика:

  • CategoryBase — базовая схема для сериализации категории в ответах.
  • model_config = {'from_attributes': True} — позволяет создавать схему из ORM-объекта.
  • QuestionResponse.category — Optional[CategoryBase], чтобы вопрос мог быть без категории.

Шаг 5: Роутер categories.py

# app/routers/categories.py
from flask import Blueprint, request, jsonify
from sqlalchemy import select
from app.models import db
from app.models.category import Category
from app.schemas.question import CategoryCreate

categories_bp = Blueprint('categories', __name__, url_prefix='/categories')


@categories_bp.route('/', methods=['GET'])
def get_categories():
    """Получение списка всех категорий."""
    categories = db.session.execute(select(Category)).scalars().all()
    result = [{'id': c.id, 'name': c.name, 'description': c.description}
              for c in categories]
    return jsonify(result), 200


@categories_bp.route('/', methods=['POST'])
def create_category():
    """Создание новой категории."""
    data = request.get_json()
    if not data or 'name' not in data:
        return jsonify({'error': 'name обязателен'}), 400

    # Валидация через Pydantic
    try:
        schema = CategoryCreate(**data)
    except Exception as e:
        return jsonify({'error': str(e)}), 422

    # Проверка уникальности
    existing = db.session.execute(
        select(Category).filter_by(name=schema.name)
    ).scalar_one_or_none()
    if existing:
        return jsonify({'error': f'Категория "{schema.name}" уже существует'}), 409

    category = Category(name=schema.name, description=schema.description)
    db.session.add(category)
    db.session.commit()
    return jsonify({'id': category.id, 'name': category.name}), 201


@categories_bp.route('/<int:id>', methods=['PUT'])
def update_category(id):
    """Обновление категории по ID."""
    category = db.session.get(Category, id)
    if category is None:
        return jsonify({'message': 'Категория не найдена'}), 404

    data = request.get_json()
    if not data:
        return jsonify({'message': 'Нет данных'}), 400

    if 'name' in data:
        category.name = data['name']
    if 'description' in data:
        category.description = data['description']

    db.session.commit()
    return jsonify({'id': category.id, 'name': category.name}), 200


@categories_bp.route('/<int:id>', methods=['DELETE'])
def delete_category(id):
    """Удаление категории по ID."""
    category = db.session.get(Category, id)
    if category is None:
        return jsonify({'message': 'Категория не найдена'}), 404

    db.session.delete(category)
    db.session.commit()
    return jsonify({'message': f'Категория {id} удалена'}), 200

Шаг 6: Обновить questions.py — поддержка category

# app/routers/questions.py (обновлённые эндпоинты)

@questions_bp.route('/', methods=['GET'])
def get_questions():
    """Получение списка вопросов с информацией о категории."""
    questions = db.session.execute(select(Question)).scalars().all()
    result = []
    for q in questions:
        q_data = {'id': q.id, 'text': q.text, 'category': None}
        if q.category:
            q_data['category'] = {
                'id': q.category.id,
                'name': q.category.name,
                'description': q.category.description
            }
        result.append(q_data)
    return jsonify(result)


@questions_bp.route('/', methods=['POST'])
def create_question():
    """Создание нового вопроса с опциональной категорией."""
    data = request.get_json()
    if not data or 'text' not in data:
        return jsonify({'error': 'No question text provided'}), 400

    question = Question(
        text=data['text'],
        category_id=data.get('category_id')   # опциональный внешний ключ
    )
    db.session.add(question)
    db.session.commit()
    return jsonify({'message': 'Вопрос создан', 'id': question.id}), 201

Шаг 7: Зарегистрировать categories_bp в create_app()

# app/__init__.py
from app.routers.categories import categories_bp
app.register_blueprint(categories_bp)

Проверка в VS Code

Терминал — запуск приложения

# Активировать venv
.venv\Scripts\activate      # Windows
# source .venv/bin/activate  # Linux/macOS

# Запустить
flask run --debug

launch.json для отладки (F5)

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Flask",
      "type": "debugpy",
      "request": "launch",
      "module": "flask",
      "args": ["run", "--debug"],
      "env": {
        "FLASK_APP": "run.py",
        "FLASK_ENV": "development"
      },
      "jinja": true,
      "justMyCode": true
    }
  ]
}

Точки останова

Для проверки логики add / commit:

  1. Открыть app/routers/categories.py
  2. Кликнуть слева от строки db.session.commit() в create_category — красный кружок
  3. F5 → Postman: POST /categories с {"name": "Тест"}
  4. VS Code остановится на commit — в панели Debug можно проверить значение category.id (будет None до commit)
  5. F10 (step over) — после commit category.id получает значение из БД

Тестирование в Postman

# 1. Создать категорию
POST http://localhost:5000/categories/
Body: {"name": "Транспорт", "description": "Вопросы о транспорте"}
Expected: 201 {"id": 1, "name": "Транспорт"}

# 2. Получить все категории
GET http://localhost:5000/categories/
Expected: 200 [{"id": 1, "name": "Транспорт", ...}]

# 3. Создать вопрос с категорией
POST http://localhost:5000/questions/
Body: {"text": "Нужны ли выделенные полосы для автобусов?", "category_id": 1}
Expected: 201 {"message": "Вопрос создан", "id": 1}

# 4. Получить вопросы с категорией
GET http://localhost:5000/questions/
Expected: 200 [{"id": 1, "text": "...", "category": {"id": 1, "name": "Транспорт"}}]

# 5. Обновить категорию
PUT http://localhost:5000/categories/1
Body: {"name": "Городской транспорт"}
Expected: 200

# 6. Удалить категорию
DELETE http://localhost:5000/categories/1
Expected: 200

Связь с разделами курса

← К оглавлению урока