← Назад к курсу

Полное руководство по разработке веб-приложений на Flask

1. Введение в Flask

Что такое Flask?

Flask — это микрофреймворк для веб-разработки на Python, который предоставляет только базовые возможности, оставляя выбор дополнительных компонентов разработчику.

Основные принципы:

  • Минимализм — только необходимый функционал
  • Расширяемость через extensions
  • Простота и гибкость

Установка Flask

# Создание виртуального окружения
python -m venv venv

# Активация (Windows)
venv\Scripts\activate

# Активация (Linux/Mac)
source venv/bin/activate

# Установка Flask
pip install flask

# Для разработки также полезно установить
pip install python-dotenv flask-sqlalchemy flask-wtf flask-login flask-migrate

# Проверка установки
python -c "import flask; print(flask.__version__)"

2. Создание первого приложения

Минимальное приложение

# app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def home():
    return 'Привет, Flask!'

@app.route('/hello/<name>')
def hello(name):
    return f'Привет, {name}!'

if __name__ == '__main__':
    app.run(debug=True)

Структура проекта Flask

myproject/
├── app.py                 # Основной файл приложения
├── config.py              # Конфигурация
├── requirements.txt       # Зависимости
├── .env                   # Переменные окружения
├── .gitignore
├── instance/              # Папка instance (данные БД)
├── app/                   # Пакет приложения
│   ├── __init__.py       # Инициализация приложения
│   ├── models.py         # Модели данных
│   ├── views.py          # Представления (роуты)
│   ├── forms.py          # Формы
│   ├── templates/        # Шаблоны Jinja2
│   │   ├── base.html
│   │   └── ...
│   ├── static/           # Статические файлы
│   │   ├── css/
│   │   ├── js/
│   │   └── images/
│   └── auth/             # Модуль аутентификации
│       ├── __init__.py
│       └── routes.py
└── migrations/            # Миграции базы данных

Фабрика приложений (Application Factory)

# app/__init__.py
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
import os

# Инициализация расширений
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()

def create_app(config_class='config.Config'):
    """Фабрика для создания экземпляра приложения"""
    app = Flask(__name__)
    
    # Загрузка конфигурации
    app.config.from_object(config_class)
    
    # Инициализация расширений с приложением
    db.init_app(app)
    login_manager.init_app(app)
    migrate.init_app(app, db)
    
    # Настройка LoginManager
    login_manager.login_view = 'auth.login'
    login_manager.login_message = 'Пожалуйста, войдите для доступа к этой странице.'
    login_manager.login_message_category = 'info'
    
    # Регистрация Blueprints
    from app.auth import bp as auth_bp
    from app.main import bp as main_bp
    from app.blog import bp as blog_bp
    
    app.register_blueprint(auth_bp, url_prefix='/auth')
    app.register_blueprint(main_bp)
    app.register_blueprint(blog_bp, url_prefix='/blog')
    
    # Создание таблиц БД
    with app.app_context():
        db.create_all()
    
    return app

3. Конфигурация приложения

Конфигурационные классы

# config.py
import os
from dotenv import load_dotenv

load_dotenv()  # Загрузка переменных из .env

class Config:
    """Базовая конфигурация"""
    SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    
    # Почта
    MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.gmail.com')
    MAIL_PORT = int(os.environ.get('MAIL_PORT', 587))
    MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']
    MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
    MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
    MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER')
    
    # Пагинация
    POSTS_PER_PAGE = 10
    COMMENTS_PER_PAGE = 20

class DevelopmentConfig(Config):
    """Конфигурация для разработки"""
    DEBUG = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or \
        'sqlite:///' + os.path.join(os.path.dirname(__file__), 'instance/dev.db')

class TestingConfig(Config):
    """Конфигурация для тестирования"""
    TESTING = True
    SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
        'sqlite:///:memory:'
    WTF_CSRF_ENABLED = False

class ProductionConfig(Config):
    """Конфигурация для продакшена"""
    SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \
        'sqlite:///' + os.path.join(os.path.dirname(__file__), 'instance/prod.db')
    
    # Безопасность
    SESSION_COOKIE_SECURE = True
    REMEMBER_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
    REMEMBER_COOKIE_HTTPONLY = True

# Словарь конфигураций
config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

Файл .env

# .env
SECRET_KEY=your-secret-key-here-change-in-production
DATABASE_URL=postgresql://username:password@localhost/dbname
DEV_DATABASE_URL=sqlite:///instance/dev.db
MAIL_USERNAME=your-email@gmail.com
MAIL_PASSWORD=your-email-password

4. Модели данных (SQLAlchemy)

Определение моделей

# app/models.py
from datetime import datetime
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from app import db, login_manager

class User(UserMixin, db.Model):
    """Модель пользователя"""
    __tablename__ = 'users'
    
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), unique=True, index=True, nullable=False)
    email = db.Column(db.String(120), unique=True, index=True, nullable=False)
    password_hash = db.Column(db.String(256))
    is_active = db.Column(db.Boolean, default=True)
    is_admin = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    # Связи
    posts = db.relationship('Post', backref='author', lazy='dynamic', cascade='all, delete-orphan')
    comments = db.relationship('Comment', backref='author', lazy='dynamic')
    
    def __repr__(self):
        return f'<User {self.username}>'
    
    def set_password(self, password):
        """Хеширование пароля"""
        self.password_hash = generate_password_hash(password)
    
    def check_password(self, password):
        """Проверка пароля"""
        return check_password_hash(self.password_hash, password)
    
    @property
    def is_authenticated(self):
        return True
    
    @property
    def is_anonymous(self):
        return False
    
    def get_id(self):
        return str(self.id)

@login_manager.user_loader
def load_user(user_id):
    """Загрузка пользователя для Flask-Login"""
    return User.query.get(int(user_id))

class Post(db.Model):
    """Модель поста блога"""
    __tablename__ = 'posts'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text, nullable=False)
    slug = db.Column(db.String(250), unique=True, nullable=False)
    is_published = db.Column(db.Boolean, default=False)
    views = db.Column(db.Integer, default=0)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Внешние ключи
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('categories.id'))
    
    # Связи
    comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
    tags = db.relationship('Tag', secondary='post_tags', backref=db.backref('posts', lazy='dynamic'))
    
    def __repr__(self):
        return f'<Post {self.title}>'
    
    def increment_views(self):
        """Увеличение счетчика просмотров"""
        self.views += 1
        db.session.commit()

class Category(db.Model):
    """Модель категории"""
    __tablename__ = 'categories'
    
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100), unique=True, nullable=False)
    slug = db.Column(db.String(120), unique=True, nullable=False)
    description = db.Column(db.Text)
    
    # Связи
    posts = db.relationship('Post', backref='category', lazy='dynamic')
    
    def __repr__(self):
        return f'<Category {self.name}>'

class Tag(db.Model):
    """Модель тега"""
    __tablename__ = 'tags'
    
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)
    slug = db.Column(db.String(70), unique=True, nullable=False)
    
    def __repr__(self):
        return f'<Tag {self.name}>'

# Таблица многие-ко-многим для постов и тегов
post_tags = db.Table('post_tags',
    db.Column('post_id', db.Integer, db.ForeignKey('posts.id'), primary_key=True),
    db.Column('tag_id', db.Integer, db.ForeignKey('tags.id'), primary_key=True)
)

class Comment(db.Model):
    """Модель комментария"""
    __tablename__ = 'comments'
    
    id = db.Column(db.Integer, primary_key=True)
    content = db.Column(db.Text, nullable=False)
    is_approved = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    
    # Внешние ключи
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
    parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True)
    
    # Связи для древовидных комментариев
    replies = db.relationship('Comment', 
                             backref=db.backref('parent', remote_side=[id]),
                             lazy='dynamic')
    
    def __repr__(self):
        return f'<Comment {self.id}>'

5. Маршрутизация (Routing)

Базовые маршруты

# app/main/routes.py
from flask import render_template, request, flash, redirect, url_for, abort, jsonify
from flask_login import login_required, current_user
from app import db
from app.main import bp
from app.models import Post, Category, Comment
from app.main.forms import CommentForm
from sqlalchemy import or_
import markdown

@bp.route('/')
@bp.route('/index')
def index():
    """Главная страница"""
    page = request.args.get('page', 1, type=int)
    
    # Получаем посты с пагинацией
    posts = Post.query.filter_by(is_published=True)\
        .order_by(Post.created_at.desc())\
        .paginate(page=page, per_page=10, error_out=False)
    
    # Получаем популярные посты
    popular_posts = Post.query.filter_by(is_published=True)\
        .order_by(Post.views.desc())\
        .limit(5).all()
    
    # Получаем все категории
    categories = Category.query.all()
    
    return render_template('index.html', 
                          posts=posts,
                          popular_posts=popular_posts,
                          categories=categories)

@bp.route('/about')
def about():
    """Страница "О сайте" """
    return render_template('about.html')

@bp.route('/contact', methods=['GET', 'POST'])
def contact():
    """Страница контактов"""
    if request.method == 'POST':
        # Обработка формы контактов
        name = request.form.get('name')
        email = request.form.get('email')
        message = request.form.get('message')
        
        # Здесь можно отправить email
        flash('Сообщение отправлено! Мы ответим вам в ближайшее время.', 'success')
        return redirect(url_for('main.contact'))
    
    return render_template('contact.html')

@bp.route('/search')
def search():
    """Поиск по сайту"""
    query = request.args.get('q', '')
    page = request.args.get('page', 1, type=int)
    
    if query:
        # Поиск по заголовку и содержанию
        posts = Post.query.filter(
            or_(
                Post.title.ilike(f'%{query}%'),
                Post.content.ilike(f'%{query}%')
            ),
            Post.is_published == True
        ).order_by(Post.created_at.desc())\
         .paginate(page=page, per_page=10, error_out=False)
    else:
        posts = []
    
    return render_template('search.html', 
                          posts=posts, 
                          query=query)

@bp.route('/post/<slug>', methods=['GET', 'POST'])
def post_detail(slug):
    """Детальная страница поста"""
    post = Post.query.filter_by(slug=slug, is_published=True).first_or_404()
    
    # Увеличиваем счетчик просмотров
    post.increment_views()
    
    # Форма комментария
    form = CommentForm()
    
    if form.validate_on_submit():
        if not current_user.is_authenticated:
            flash('Для добавления комментария необходимо войти.', 'warning')
            return redirect(url_for('auth.login'))
        
        comment = Comment(
            content=form.content.data,
            user_id=current_user.id,
            post_id=post.id,
            is_approved=True  # Для зарегистрированных пользователей
        )
        
        db.session.add(comment)
        db.session.commit()
        
        flash('Комментарий добавлен!', 'success')
        return redirect(url_for('main.post_detail', slug=slug))
    
    # Получаем комментарии
    comments = Comment.query.filter_by(post_id=post.id, is_approved=True)\
        .order_by(Comment.created_at.desc()).all()
    
    # Конвертируем markdown в HTML
    html_content = markdown.markdown(post.content)
    
    return render_template('post_detail.html',
                          post=post,
                          html_content=html_content,
                          form=form,
                          comments=comments)

@bp.route('/category/<slug>')
def category_posts(slug):
    """Посты по категории"""
    category = Category.query.filter_by(slug=slug).first_or_404()
    page = request.args.get('page', 1, type=int)
    
    posts = Post.query.filter_by(category_id=category.id, is_published=True)\
        .order_by(Post.created_at.desc())\
        .paginate(page=page, per_page=10, error_out=False)
    
    return render_template('category.html',
                          category=category,
                          posts=posts)

# API эндпоинты
@bp.route('/api/posts')
def api_posts():
    """API для получения списка постов"""
    page = request.args.get('page', 1, type=int)
    per_page = request.args.get('per_page', 10, type=int)
    
    posts = Post.query.filter_by(is_published=True)\
        .order_by(Post.created_at.desc())\
        .paginate(page=page, per_page=per_page)
    
    return jsonify({
        'posts': [{
            'id': post.id,
            'title': post.title,
            'slug': post.slug,
            'excerpt': post.content[:200] + '...' if len(post.content) > 200 else post.content,
            'created_at': post.created_at.isoformat(),
            'author': post.author.username,
            'views': post.views
        } for post in posts.items],
        'total': posts.total,
        'pages': posts.pages,
        'current_page': posts.page
    })

@bp.errorhandler(404)
def not_found_error(error):
    """Обработка ошибки 404"""
    return render_template('errors/404.html'), 404

@bp.errorhandler(500)
def internal_error(error):
    """Обработка ошибки 500"""
    db.session.rollback()
    return render_template('errors/500.html'), 500

Blueprints (Модули приложения)

# app/main/__init__.py
from flask import Blueprint

bp = Blueprint('main', __name__)

from app.main import routes

# app/auth/__init__.py
from flask import Blueprint

bp = Blueprint('auth', __name__, template_folder='templates')

from app.auth import routes

# app/blog/__init__.py
from flask import Blueprint

bp = Blueprint('blog', __name__, template_folder='templates')

from app.blog import routes

6. Формы (WTForms)

Создание форм

# app/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField, SelectField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.models import User

class LoginForm(FlaskForm):
    """Форма входа"""
    username = StringField('Имя пользователя', 
                          validators=[DataRequired()])
    password = PasswordField('Пароль', 
                           validators=[DataRequired()])
    remember_me = BooleanField('Запомнить меня')
    submit = SubmitField('Войти')

class RegistrationForm(FlaskForm):
    """Форма регистрации"""
    username = StringField('Имя пользователя', 
                          validators=[DataRequired(), 
                                     Length(min=3, max=64)])
    email = StringField('Email', 
                       validators=[DataRequired(), Email()])
    password = PasswordField('Пароль', 
                           validators=[DataRequired(),
                                      Length(min=6)])
    password2 = PasswordField('Повторите пароль', 
                            validators=[DataRequired(), 
                                       EqualTo('password')])
    submit = SubmitField('Зарегистрироваться')
    
    def validate_username(self, username):
        """Проверка уникальности имени пользователя"""
        user = User.query.filter_by(username=username.data).first()
        if user is not None:
            raise ValidationError('Это имя пользователя уже занято.')
    
    def validate_email(self, email):
        """Проверка уникальности email"""
        user = User.query.filter_by(email=email.data).first()
        if user is not None:
            raise ValidationError('Этот email уже зарегистрирован.')

class PostForm(FlaskForm):
    """Форма создания/редактирования поста"""
    title = StringField('Заголовок', 
                       validators=[DataRequired(), 
                                  Length(min=3, max=200)])
    content = TextAreaField('Содержание', 
                           validators=[DataRequired()],
                           render_kw={"rows": 10})
    category = SelectField('Категория', 
                          coerce=int,
                          validators=[DataRequired()])
    tags = StringField('Теги (через запятую)')
    is_published = BooleanField('Опубликовать')
    submit = SubmitField('Сохранить')

class CommentForm(FlaskForm):
    """Форма комментария"""
    content = TextAreaField('Комментарий', 
                           validators=[DataRequired(),
                                      Length(min=3, max=1000)],
                           render_kw={"rows": 4,
                                     "placeholder": "Введите ваш комментарий..."})
    submit = SubmitField('Отправить')

class ProfileForm(FlaskForm):
    """Форма редактирования профиля"""
    username = StringField('Имя пользователя', 
                          validators=[DataRequired(), 
                                     Length(min=3, max=64)])
    email = StringField('Email', 
                       validators=[DataRequired(), Email()])
    bio = TextAreaField('О себе', 
                       render_kw={"rows": 4})
    submit = SubmitField('Обновить')

class ContactForm(FlaskForm):
    """Форма обратной связи"""
    name = StringField('Ваше имя', 
                      validators=[DataRequired()])
    email = StringField('Email', 
                       validators=[DataRequired(), Email()])
    message = TextAreaField('Сообщение', 
                           validators=[DataRequired(),
                                      Length(min=10, max=2000)],
                           render_kw={"rows": 5})
    submit = SubmitField('Отправить')

7. Шаблоны (Jinja2)

Базовый шаблон

<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Мой Flask Блог{% endblock %}</title>
    
    <!-- Bootstrap 5 CSS -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
    
    <!-- Иконки Bootstrap -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.1/font/bootstrap-icons.css">
    
    <!-- Собственные стили -->
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    
    {% block head %}{% endblock %}
</head>
<body>
    <!-- Навигация -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('main.index') }}">
                <i class="bi bi-journal-text"></i> Flask Blog
            </a>
            
            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
                <span class="navbar-toggler-icon"></span>
            </button>
            
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav me-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('main.index') }}">
                            <i class="bi bi-house"></i> Главная
                        </a>
                    </li>
                    
                    {% if current_user.is_authenticated %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('blog.create_post') }}">
                            <i class="bi bi-plus-circle"></i> Новый пост
                        </a>
                    </li>
                    {% endif %}
                    
                    <!-- Категории -->
                    {% set categories = [] %}
                    {% for category in g.categories %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{ url_for('main.category_posts', slug=category.slug) }}">
                                {{ category.name }}
                            </a>
                        </li>
                    {% endfor %}
                    
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('main.about') }}">
                            <i class="bi bi-info-circle"></i> О сайте
                        </a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('main.contact') }}">
                            <i class="bi bi-envelope"></i> Контакты
                        </a>
                    </li>
                </ul>
                
                <!-- Поиск -->
                <form class="d-flex me-3" action="{{ url_for('main.search') }}" method="get">
                    <input class="form-control me-2" type="search" name="q" placeholder="Поиск..." 
                           value="{{ request.args.get('q', '') }}">
                    <button class="btn btn-outline-light" type="submit">
                        <i class="bi bi-search"></i>
                    </button>
                </form>
                
                <!-- Аутентификация -->
                <ul class="navbar-nav">
                    {% if current_user.is_authenticated %}
                        <li class="nav-item dropdown">
                            <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" 
                               data-bs-toggle="dropdown">
                                <i class="bi bi-person-circle"></i> {{ current_user.username }}
                            </a>
                            <ul class="dropdown-menu">
                                <li>
                                    <a class="dropdown-item" href="{{ url_for('auth.profile') }}">
                                        <i class="bi bi-person"></i> Профиль
                                    </a>
                                </li>
                                {% if current_user.is_admin %}
                                <li>
                                    <a class="dropdown-item" href="{{ url_for('admin.index') }}">
                                        <i class="bi bi-gear"></i> Админка
                                    </a>
                                </li>
                                {% endif %}
                                <li><hr class="dropdown-divider"></li>
                                <li>
                                    <a class="dropdown-item" href="{{ url_for('auth.logout') }}">
                                        <i class="bi bi-box-arrow-right"></i> Выйти
                                    </a>
                                </li>
                            </ul>
                        </li>
                    {% else %}
                        <li class="nav-item">
                            <a class="nav-link" href="{{ url_for('auth.login') }}">
                                <i class="bi bi-box-arrow-in-right"></i> Войти
                            </a>
                        </li>
                        <li class="nav-item">
                            <a class="nav-link" href="{{ url_for('auth.register') }}">
                                <i class="bi bi-person-plus"></i> Регистрация
                            </a>
                        </li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>

    <!-- Flash сообщения -->
    <div class="container mt-3">
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ category if category != 'message' else 'info' }} alert-dismissible fade show">
                        {{ message }}
                        <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
                    </div>
                {% endfor %}
            {% endif %}
        {% endwith %}
    </div>

    <!-- Основной контент -->
    <main class="container py-4">
        {% block content %}{% endblock %}
    </main>

    <!-- Футер -->
    <footer class="bg-dark text-white py-4 mt-5">
        <div class="container">
            <div class="row">
                <div class="col-md-4">
                    <h5>Flask Blog</h5>
                    <p>Простой блог на Flask для демонстрации возможностей фреймворка.</p>
                </div>
                <div class="col-md-4">
                    <h5>Навигация</h5>
                    <ul class="list-unstyled">
                        <li><a href="{{ url_for('main.index') }}" class="text-white-50">Главная</a></li>
                        <li><a href="{{ url_for('main.about') }}" class="text-white-50">О сайте</a></li>
                        <li><a href="{{ url_for('main.contact') }}" class="text-white-50">Контакты</a></li>
                    </ul>
                </div>
                <div class="col-md-4">
                    <h5>Контакты</h5>
                    <p><i class="bi bi-envelope"></i> info@flaskblog.com</p>
                    <p>&copy; {{ current_year }} Flask Blog. Все права защищены.</p>
                </div>
            </div>
        </div>
    </footer>

    <!-- Bootstrap JS -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
    
    <!-- Собственные скрипты -->
    <script src="{{ url_for('static', filename='js/main.js') }}"></script>
    
    {% block scripts %}{% endblock %}
</body>
</html>

Шаблон списка постов

<!-- app/templates/index.html -->
{% extends "base.html" %}

{% block title %}Главная - Flask Blog{% endblock %}

{% block content %}
<div class="row">
    <!-- Основной контент -->
    <div class="col-lg-8">
        <h1 class="mb-4">Последние посты</h1>
        
        {% for post in posts.items %}
        <article class="card mb-4 shadow-sm">
            {% if post.image_url %}
            <img src="{{ post.image_url }}" class="card-img-top" alt="{{ post.title }}">
            {% endif %}
            
            <div class="card-body">
                <h2 class="card-title">
                    <a href="{{ url_for('main.post_detail', slug=post.slug) }}" 
                       class="text-decoration-none text-dark">
                        {{ post.title }}
                    </a>
                </h2>
                
                <div class="text-muted mb-3">
                    <small>
                        <i class="bi bi-calendar"></i> {{ post.created_at.strftime('%d.%m.%Y %H:%M') }} |
                        <i class="bi bi-person"></i> {{ post.author.username }} |
                        <i class="bi bi-eye"></i> {{ post.views }} |
                        <i class="bi bi-chat"></i> {{ post.comments.count() }}
                        
                        {% if post.category %}
                        | <a href="{{ url_for('main.category_posts', slug=post.category.slug) }}" 
                             class="text-decoration-none">
                            <i class="bi bi-folder"></i> {{ post.category.name }}
                        </a>
                        {% endif %}
                    </small>
                </div>
                
                <p class="card-text">
                    {{ post.content|striptags|truncate(300) }}
                </p>
                
                <div class="d-flex justify-content-between align-items-center">
                    <a href="{{ url_for('main.post_detail', slug=post.slug) }}" 
                       class="btn btn-primary">
                        Читать далее <i class="bi bi-arrow-right"></i>
                    </a>
                    
                    <div>
                        {% for tag in post.tags %}
                        <span class="badge bg-secondary me-1">{{ tag.name }}</span>
                        {% endfor %}
                    </div>
                </div>
            </div>
        </article>
        {% else %}
        <div class="alert alert-info">
            Постов пока нет. Будьте первым, кто напишет пост!
        </div>
        {% endfor %}
        
        <!-- Пагинация -->
        {% if posts.pages > 1 %}
        <nav aria-label="Page navigation">
            <ul class="pagination justify-content-center">
                {% if posts.has_prev %}
                <li class="page-item">
                    <a class="page-link" href="{{ url_for('main.index', page=posts.prev_num) }}">
                        <i class="bi bi-chevron-left"></i> Назад
                    </a>
                </li>
                {% endif %}
                
                {% for page_num in posts.iter_pages(left_edge=2, left_current=2, right_current=3, right_edge=2) %}
                    {% if page_num %}
                        {% if page_num == posts.page %}
                        <li class="page-item active">
                            <span class="page-link">{{ page_num }}</span>
                        </li>
                        {% else %}
                        <li class="page-item">
                            <a class="page-link" href="{{ url_for('main.index', page=page_num) }}">
                                {{ page_num }}
                            </a>
                        </li>
                        {% endif %}
                    {% else %}
                        <li class="page-item disabled">
                            <span class="page-link">...</span>
                        </li>
                    {% endif %}
                {% endfor %}
                
                {% if posts.has_next %}
                <li class="page-item">
                    <a class="page-link" href="{{ url_for('main.index', page=posts.next_num) }}">
                        Вперед <i class="bi bi-chevron-right"></i>
                    </a>
                </li>
                {% endif %}
            </ul>
        </nav>
        {% endif %}
    </div>
    
    <!-- Сайдбар -->
    <div class="col-lg-4">
        <!-- Категории -->
        <div class="card mb-4 shadow-sm">
            <div class="card-header bg-primary text-white">
                <h5 class="mb-0"><i class="bi bi-folder"></i> Категории</h5>
            </div>
            <div class="card-body">
                <ul class="list-unstyled mb-0">
                    {% for category in categories %}
                    <li class="mb-2">
                        <a href="{{ url_for('main.category_posts', slug=category.slug) }}" 
                           class="text-decoration-none">
                            <i class="bi bi-folder2"></i> {{ category.name }}
                            <span class="badge bg-secondary float-end">{{ category.posts.count() }}</span>
                        </a>
                    </li>
                    {% endfor %}
                </ul>
            </div>
        </div>
        
        <!-- Популярные посты -->
        <div class="card mb-4 shadow-sm">
            <div class="card-header bg-success text-white">
                <h5 class="mb-0"><i class="bi bi-fire"></i> Популярные посты</h5>
            </div>
            <div class="card-body">
                <ul class="list-unstyled mb-0">
                    {% for post in popular_posts %}
                    <li class="mb-2">
                        <a href="{{ url_for('main.post_detail', slug=post.slug) }}" 
                           class="text-decoration-none">
                            {{ post.title|truncate(40) }}
                            <span class="text-muted float-end">
                                <i class="bi bi-eye"></i> {{ post.views }}
                            </span>
                        </a>
                    </li>
                    {% endfor %}
                </ul>
            </div>
        </div>
        
        <!-- Теги -->
        <div class="card shadow-sm">
            <div class="card-header bg-info text-white">
                <h5 class="mb-0"><i class="bi bi-tags"></i> Теги</h5>
            </div>
            <div class="card-body">
                {% for tag in tags %}
                <a href="{{ url_for('main.tag_posts', slug=tag.slug) }}" 
                   class="badge bg-light text-dark text-decoration-none me-1 mb-1">
                    {{ tag.name }}
                </a>
                {% endfor %}
            </div>
        </div>
    </div>
</div>
{% endblock %}

Шаблон формы

<!-- app/templates/auth/login.html -->
{% extends "base.html" %}

{% block title %}Вход - Flask Blog{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card shadow">
            <div class="card-header bg-primary text-white">
                <h4 class="mb-0"><i class="bi bi-box-arrow-in-right"></i> Вход в систему</h4>
            </div>
            
            <div class="card-body">
                <form method="POST" action="{{ url_for('auth.login') }}">
                    {{ form.hidden_tag() }}
                    
                    <div class="mb-3">
                        {{ form.username.label(class="form-label") }}
                        {{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
                        {% for error in form.username.errors %}
                        <div class="invalid-feedback">{{ error }}</div>
                        {% endfor %}
                    </div>
                    
                    <div class="mb-3">
                        {{ form.password.label(class="form-label") }}
                        {{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
                        {% for error in form.password.errors %}
                        <div class="invalid-feedback">{{ error }}</div>
                        {% endfor %}
                    </div>
                    
                    <div class="mb-3 form-check">
                        {{ form.remember_me(class="form-check-input") }}
                        {{ form.remember_me.label(class="form-check-label") }}
                    </div>
                    
                    <div class="d-grid gap-2">
                        {{ form.submit(class="btn btn-primary") }}
                    </div>
                </form>
                
                <hr class="my-4">
                
                <div class="text-center">
                    <p class="mb-2">Еще нет аккаунта?</p>
                    <a href="{{ url_for('auth.register') }}" class="btn btn-outline-primary">
                        Зарегистрироваться
                    </a>
                </div>
            </div>
        </div>
    </div>
</div>
{% endblock %}

8. Аутентификация (Flask-Login)

Реализация аутентификации

# app/auth/routes.py
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from werkzeug.urls import url_parse
from app import db
from app.auth import bp
from app.auth.forms import LoginForm, RegistrationForm, ProfileForm, ResetPasswordRequestForm, ResetPasswordForm
from app.models import User
from app.auth.email import send_password_reset_email

@bp.route('/login', methods=['GET', 'POST'])
def login():
    """Страница входа"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    
    form = LoginForm()
    
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        
        if user is None or not user.check_password(form.password.data):
            flash('Неверное имя пользователя или пароль', 'danger')
            return redirect(url_for('auth.login'))
        
        if not user.is_active:
            flash('Аккаунт деактивирован', 'warning')
            return redirect(url_for('auth.login'))
        
        login_user(user, remember=form.remember_me.data)
        
        # Перенаправление на следующую страницу
        next_page = request.args.get('next')
        if not next_page or url_parse(next_page).netloc != '':
            next_page = url_for('main.index')
        
        return redirect(next_page)
    
    return render_template('auth/login.html', form=form)

@bp.route('/logout')
@login_required
def logout():
    """Выход из системы"""
    logout_user()
    flash('Вы успешно вышли из системы', 'success')
    return redirect(url_for('main.index'))

@bp.route('/register', methods=['GET', 'POST'])
def register():
    """Страница регистрации"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    
    form = RegistrationForm()
    
    if form.validate_on_submit():
        user = User(
            username=form.username.data,
            email=form.email.data
        )
        user.set_password(form.password.data)
        
        db.session.add(user)
        db.session.commit()
        
        flash('Поздравляем! Вы успешно зарегистрировались.', 'success')
        login_user(user)
        
        return redirect(url_for('main.index'))
    
    return render_template('auth/register.html', form=form)

@bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
    """Страница профиля"""
    form = ProfileForm()
    
    if form.validate_on_submit():
        current_user.username = form.username.data
        current_user.email = form.email.data
        current_user.bio = form.bio.data
        
        db.session.commit()
        flash('Профиль обновлен', 'success')
        return redirect(url_for('auth.profile'))
    
    elif request.method == 'GET':
        form.username.data = current_user.username
        form.email.data = current_user.email
        form.bio.data = current_user.bio
    
    return render_template('auth/profile.html', form=form)

@bp.route('/reset-password-request', methods=['GET', 'POST'])
def reset_password_request():
    """Запрос сброса пароля"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    
    form = ResetPasswordRequestForm()
    
    if form.validate_on_submit():
        user = User.query.filter_by(email=form.email.data).first()
        if user:
            send_password_reset_email(user)
        
        flash('Инструкции по сбросу пароля отправлены на ваш email', 'info')
        return redirect(url_for('auth.login'))
    
    return render_template('auth/reset_password_request.html', form=form)

@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
    """Сброс пароля по токену"""
    if current_user.is_authenticated:
        return redirect(url_for('main.index'))
    
    user = User.verify_reset_password_token(token)
    if not user:
        flash('Неверный или просроченный токен', 'danger')
        return redirect(url_for('auth.reset_password_request'))
    
    form = ResetPasswordForm()
    
    if form.validate_on_submit():
        user.set_password(form.password.data)
        db.session.commit()
        
        flash('Пароль успешно изменен', 'success')
        return redirect(url_for('auth.login'))
    
    return render_template('auth/reset_password.html', form=form)

# Декоратор для проверки администратора
def admin_required(f):
    """Декоратор для проверки прав администратора"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or not current_user.is_admin:
            abort(403)
        return f(*args, **kwargs)
    return decorated_function

9. Административная панель

Flask-Admin

# app/admin.py
from flask import redirect, url_for, request
from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from flask_login import current_user
from app import db
from app.models import User, Post, Category, Comment, Tag

class AdminModelView(ModelView):
    """Базовый класс для административных представлений"""
    def is_accessible(self):
        return current_user.is_authenticated and current_user.is_admin
    
    def inaccessible_callback(self, name, **kwargs):
        return redirect(url_for('auth.login', next=request.url))

class UserAdminView(AdminModelView):
    """Административное представление для пользователей"""
    column_list = ['username', 'email', 'is_active', 'is_admin', 'created_at']
    column_searchable_list = ['username', 'email']
    column_filters = ['is_active', 'is_admin', 'created_at']
    form_columns = ['username', 'email', 'is_active', 'is_admin']
    
    def on_model_change(self, form, model, is_created):
        if 'password' in form:
            if form.password.data:
                model.set_password(form.password.data)

class PostAdminView(AdminModelView):
    """Административное представление для постов"""
    column_list = ['title', 'author', 'category', 'is_published', 'views', 'created_at']
    column_searchable_list = ['title', 'content']
    column_filters = ['is_published', 'created_at', 'category']
    form_columns = ['title', 'content', 'author', 'category', 'tags', 'is_published']
    
    form_widget_args = {
        'content': {
            'rows': 20
        }
    }

class MyAdminIndexView(AdminIndexView):
    """Кастомное представление главной страницы админки"""
    @expose('/')
    def index(self):
        if not current_user.is_authenticated or not current_user.is_admin:
            return redirect(url_for('auth.login'))
        
        stats = {
            'users': User.query.count(),
            'posts': Post.query.count(),
            'categories': Category.query.count(),
            'comments': Comment.query.count()
        }
        
        return self.render('admin/index.html', stats=stats)

# Инициализация Flask-Admin
admin = Admin(name='Flask Blog Admin', 
              template_mode='bootstrap4',
              index_view=MyAdminIndexView())

def init_admin(app):
    """Инициализация административной панели"""
    admin.init_app(app)
    
    # Добавление представлений
    admin.add_view(UserAdminView(User, db.session, name='Пользователи'))
    admin.add_view(PostAdminView(Post, db.session, name='Посты'))
    admin.add_view(AdminModelView(Category, db.session, name='Категории'))
    admin.add_view(AdminModelView(Comment, db.session, name='Комментарии'))
    admin.add_view(AdminModelView(Tag, db.session, name='Теги'))

10. Миграции базы данных (Flask-Migrate)

Настройка миграций

# Инициализация миграций
flask db init

# Создание миграции
flask db migrate -m "Initial migration"

# Применение миграций
flask db upgrade

# Откат миграции
flask db downgrade

# Просмотр истории миграций
flask db history

# Создание ревизии вручную
flask db revision -m "Add new column"

Пример миграции

# migrations/versions/xxxx_initial_migration.py
"""Initial migration

Revision ID: xxxx
Revises: 
Create Date: 2024-01-01 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa

def upgrade():
    # Создание таблицы users
    op.create_table('users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('username', sa.String(length=64), nullable=False),
        sa.Column('email', sa.String(length=120), nullable=False),
        sa.Column('password_hash', sa.String(length=256), nullable=True),
        sa.Column('is_active', sa.Boolean(), nullable=True),
        sa.Column('is_admin', sa.Boolean(), nullable=True),
        sa.Column('created_at', sa.DateTime(), nullable=True),
        sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
    op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
    
    # Создание таблицы categories
    op.create_table('categories',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.String(length=100), nullable=False),
        sa.Column('slug', sa.String(length=120), nullable=False),
        sa.Column('description', sa.Text(), nullable=True),
        sa.PrimaryKeyConstraint('id'),
        sa.UniqueConstraint('name'),
        sa.UniqueConstraint('slug')
    )

def downgrade():
    op.drop_table('categories')
    op.drop_index(op.f('ix_users_username'), table_name='users')
    op.drop_index(op.f('ix_users_email'), table_name='users')
    op.drop_table('users')

11. REST API с Flask-RESTful

Создание API

# app/api/__init__.py
from flask import Blueprint
from flask_restful import Api

bp = Blueprint('api', __name__, url_prefix='/api')
api = Api(bp)

from app.api import posts, users, auth

# app/api/posts.py
from flask_restful import Resource, reqparse, abort
from flask_login import login_required, current_user
from app import db
from app.models import Post, User

parser = reqparse.RequestParser()
parser.add_argument('title', type=str, required=True, help='Title is required')
parser.add_argument('content', type=str, required=True, help='Content is required')
parser.add_argument('category_id', type=int)
parser.add_argument('is_published', type=bool, default=False)

class PostListResource(Resource):
    """Ресурс для работы со списком постов"""
    def get(self):
        """Получить все посты"""
        posts = Post.query.filter_by(is_published=True).all()
        
        return {
            'posts': [{
                'id': post.id,
                'title': post.title,
                'slug': post.slug,
                'content': post.content[:200] + '...',
                'author': post.author.username,
                'created_at': post.created_at.isoformat(),
                'views': post.views
            } for post in posts]
        }, 200
    
    @login_required
    def post(self):
        """Создать новый пост"""
        args = parser.parse_args()
        
        post = Post(
            title=args['title'],
            content=args['content'],
            author=current_user,
            is_published=args['is_published']
        )
        
        if args.get('category_id'):
            post.category_id = args['category_id']
        
        db.session.add(post)
        db.session.commit()
        
        return {
            'message': 'Post created successfully',
            'post_id': post.id
        }, 201

class PostResource(Resource):
    """Ресурс для работы с конкретным постом"""
    def get(self, post_id):
        """Получить пост по ID"""
        post = Post.query.get_or_404(post_id)
        
        if not post.is_published and (not current_user.is_authenticated or post.author != current_user):
            abort(403, message='You do not have permission to view this post')
        
        # Увеличиваем счетчик просмотров
        post.views += 1
        db.session.commit()
        
        return {
            'post': {
                'id': post.id,
                'title': post.title,
                'content': post.content,
                'author': post.author.username,
                'category': post.category.name if post.category else None,
                'created_at': post.created_at.isoformat(),
                'updated_at': post.updated_at.isoformat(),
                'views': post.views,
                'is_published': post.is_published
            }
        }, 200
    
    @login_required
    def put(self, post_id):
        """Обновить пост"""
        post = Post.query.get_or_404(post_id)
        
        # Проверка прав
        if post.author != current_user and not current_user.is_admin:
            abort(403, message='You do not have permission to edit this post')
        
        args = parser.parse_args()
        
        post.title = args['title']
        post.content = args['content']
        post.is_published = args['is_published']
        
        if args.get('category_id'):
            post.category_id = args['category_id']
        
        db.session.commit()
        
        return {
            'message': 'Post updated successfully'
        }, 200
    
    @login_required
    def delete(self, post_id):
        """Удалить пост"""
        post = Post.query.get_or_404(post_id)
        
        # Проверка прав
        if post.author != current_user and not current_user.is_admin:
            abort(403, message='You do not have permission to delete this post')
        
        db.session.delete(post)
        db.session.commit()
        
        return {
            'message': 'Post deleted successfully'
        }, 200

# Регистрация ресурсов
from app.api import posts
api.add_resource(posts.PostListResource, '/posts')
api.add_resource(posts.PostResource, '/posts/<int:post_id>')

12. Тестирование

Модульные тесты

# tests/test_basics.py
import unittest
from flask import current_app
from app import create_app, db

class BasicsTestCase(unittest.TestCase):
    def setUp(self):
        """Настройка тестового окружения"""
        self.app = create_app('testing')
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        self.client = self.app.test_client()
    
    def tearDown(self):
        """Очистка тестового окружения"""
        db.session.remove()
        db.drop_all()
        self.app_context.pop()
    
    def test_app_exists(self):
        """Тест: приложение существует"""
        self.assertFalse(current_app is None)
    
    def test_app_is_testing(self):
        """Тест: приложение в режиме тестирования"""
        self.assertTrue(current_app.config['TESTING'])
    
    def test_home_page(self):
        """Тест: главная страница работает"""
        response = self.client.get('/')
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'Flask Blog', response.data)

# tests/test_models.py
from app.models import User, Post

class UserModelTestCase(unittest.TestCase):
    def test_password_hashing(self):
        """Тест: хеширование пароля"""
        u = User(username='testuser')
        u.set_password('testpass')
        self.assertTrue(u.check_password('testpass'))
        self.assertFalse(u.check_password('wrongpass'))
    
    def test_user_representation(self):
        """Тест: строковое представление пользователя"""
        u = User(username='testuser')
        self.assertEqual(str(u), '<User testuser>')

# tests/test_auth.py
class AuthTestCase(unittest.TestCase):
    def test_registration(self):
        """Тест: регистрация пользователя"""
        response = self.client.post('/auth/register', data={
            'username': 'testuser',
            'email': 'test@example.com',
            'password': 'testpass123',
            'password2': 'testpass123'
        }, follow_redirects=True)
        
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'Поздравляем!', response.data)
        
        # Проверка, что пользователь создан в БД
        user = User.query.filter_by(username='testuser').first()
        self.assertIsNotNone(user)
        self.assertEqual(user.email, 'test@example.com')
    
    def test_login(self):
        """Тест: вход пользователя"""
        # Сначала создаем пользователя
        u = User(username='testuser', email='test@example.com')
        u.set_password('testpass')
        db.session.add(u)
        db.session.commit()
        
        # Пытаемся войти
        response = self.client.post('/auth/login', data={
            'username': 'testuser',
            'password': 'testpass'
        }, follow_redirects=True)
        
        self.assertEqual(response.status_code, 200)
        self.assertIn(b'Главная', response.data)

Запуск тестов

# Установка тестовых зависимостей
pip install pytest coverage

# Запуск всех тестов
pytest

# Запуск с покрытием кода
coverage run -m pytest
coverage report
coverage html  # Генерация HTML отчета

# Запуск конкретного теста
pytest tests/test_auth.py::AuthTestCase::test_login

# Запуск с подробным выводом
pytest -v

13. Оптимизация и лучшие практики

Кэширование

# app/__init__.py
from flask_caching import Cache

cache = Cache(config={'CACHE_TYPE': 'redis', 'CACHE_REDIS_URL': 'redis://localhost:6379/0'})

def create_app(config_class='config.Config'):
    app = Flask(__name__)
    # ...
    cache.init_app(app)
    return app

# Использование кэширования в представлениях
@app.route('/')
@cache.cached(timeout=300)  # Кэшировать на 5 минут
def index():
    posts = Post.query.filter_by(is_published=True).order_by(Post.created_at.desc()).limit(10).all()
    return render_template('index.html', posts=posts)

# Кэширование фрагментов шаблонов
{% cache 300, 'recent_posts' %}
    <!-- Кэшируемый контент -->
{% endcache %}

Логирование

# app/__init__.py
import logging
from logging.handlers import RotatingFileHandler
import os

def create_app(config_class='config.Config'):
    app = Flask(__name__)
    
    # Настройка логирования
    if not app.debug and not app.testing:
        if not os.path.exists('logs'):
            os.mkdir('logs')
        
        file_handler = RotatingFileHandler('logs/flaskblog.log', 
                                          maxBytes=10240, 
                                          backupCount=10)
        file_handler.setFormatter(logging.Formatter(
            '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
        ))
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)
        app.logger.setLevel(logging.INFO)
        app.logger.info('FlaskBlog startup')
    
    return app

Контекстные процессоры

# app/main/__init__.py
from flask import g
from app.models import Category

@bp.app_context_processor
def inject_categories():
    """Добавление категорий во все шаблоны"""
    return dict(
        categories=Category.query.all(),
        current_year=datetime.now().year
    )

@bp.before_app_request
def before_request():
    """Выполняется перед каждым запросом"""
    g.user = current_user
    if current_user.is_authenticated:
        current_user.last_seen = datetime.utcnow()
        db.session.commit()

14. Деплой приложения

requirements.txt

# Основные зависимости
Flask==2.3.3
Werkzeug==2.3.7
Jinja2==3.1.2

# База данных
Flask-SQLAlchemy==3.0.5
Flask-Migrate==4.0.5
psycopg2-binary==2.9.7
SQLAlchemy==2.0.19

# Аутентификация и формы
Flask-Login==0.6.2
Flask-WTF==1.1.1
WTForms==3.0.1
email-validator==2.0.0

# Административная панель
Flask-Admin==1.6.1

# REST API
Flask-RESTful==0.3.10

# Дополнительные утилиты
python-dotenv==1.0.0
Markdown==3.4.4
bleach==6.0.0

# Для продакшена
gunicorn==21.2.0
whitenoise==6.5.0

# Для разработки
pytest==7.4.2
coverage==7.3.2

gunicorn_config.py

# gunicorn_config.py
bind = "0.0.0.0:8000"
workers = 4
worker_class = "sync"
threads = 2
timeout = 120
keepalive = 5
max_requests = 1000
max_requests_jitter = 50

Dockerfile

# Dockerfile
FROM python:3.11-slim

WORKDIR /app

# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
    gcc \
    postgresql-client \
    && rm -rf /var/lib/apt/lists/*

# Копирование зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Копирование приложения
COPY . .

# Создание директории для логов
RUN mkdir -p logs

# Запуск приложения
CMD ["gunicorn", "--config", "gunicorn_config.py", "app:create_app()"]

docker-compose.yml

# docker-compose.yml
version: '3.8'

services:
  web:
    build: .
    ports:
      - "8000:8000"
    environment:
      - FLASK_APP=app.py
      - FLASK_ENV=production
      - DATABASE_URL=postgresql://user:password@db/flaskblog
      - SECRET_KEY=your-secret-key
    depends_on:
      - db
      - redis
    volumes:
      - ./logs:/app/logs
      - ./static:/app/static
      - ./instance:/app/instance
  
  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=flaskblog
    volumes:
      - postgres_data:/var/lib/postgresql/data
  
  redis:
    image: redis:7-alpine
  
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./static:/static:ro
    depends_on:
      - web

volumes:
  postgres_data:

Deploy на Heroku

# Создание Procfile
echo "web: gunicorn app:create_app()" > Procfile

# Создание runtime.txt
echo "python-3.11.5" > runtime.txt

# Развертывание
heroku create your-app-name
heroku addons:create heroku-postgresql:hobby-dev
heroku addons:create heroku-redis:hobby-dev
heroku config:set SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
git push heroku main
heroku open

15. Полный пример: Приложение To-Do List

Модель

# app/todo/models.py
from datetime import datetime
from app import db

class Todo(db.Model):
    __tablename__ = 'todos'
    
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)
    description = db.Column(db.Text)
    completed = db.Column(db.Boolean, default=False)
    priority = db.Column(db.Integer, default=1)  # 1-высокий, 2-средний, 3-низкий
    due_date = db.Column(db.DateTime)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    
    # Внешний ключ
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    
    def __repr__(self):
        return f'<Todo {self.title}>'
    
    def to_dict(self):
        """Конвертация в словарь для API"""
        return {
            'id': self.id,
            'title': self.title,
            'description': self.description,
            'completed': self.completed,
            'priority': self.priority,
            'due_date': self.due_date.isoformat() if self.due_date else None,
            'created_at': self.created_at.isoformat(),
            'updated_at': self.updated_at.isoformat()
        }

API для To-Do

# app/todo/api.py
from flask_restful import Resource, reqparse
from flask_login import login_required, current_user
from app import db
from app.todo.models import Todo

todo_parser = reqparse.RequestParser()
todo_parser.add_argument('title', type=str, required=True, help='Title is required')
todo_parser.add_argument('description', type=str)
todo_parser.add_argument('completed', type=bool)
todo_parser.add_argument('priority', type=int, choices=[1, 2, 3])
todo_parser.add_argument('due_date', type=str)

class TodoListResource(Resource):
    @login_required
    def get(self):
        """Получить все задачи пользователя"""
        todos = Todo.query.filter_by(user_id=current_user.id)\
                         .order_by(Todo.priority, Todo.due_date)\
                         .all()
        
        return {
            'todos': [todo.to_dict() for todo in todos]
        }, 200
    
    @login_required
    def post(self):
        """Создать новую задачу"""
        args = todo_parser.parse_args()
        
        todo = Todo(
            title=args['title'],
            description=args.get('description'),
            completed=args.get('completed', False),
            priority=args.get('priority', 2),
            user_id=current_user.id
        )
        
        if args.get('due_date'):
            todo.due_date = datetime.fromisoformat(args['due_date'])
        
        db.session.add(todo)
        db.session.commit()
        
        return {
            'message': 'Todo created successfully',
            'todo': todo.to_dict()
        }, 201

class TodoResource(Resource):
    @login_required
    def get(self, todo_id):
        """Получить задачу по ID"""
        todo = Todo.query.filter_by(id=todo_id, user_id=current_user.id).first_or_404()
        return {'todo': todo.to_dict()}, 200
    
    @login_required
    def put(self, todo_id):
        """Обновить задачу"""
        todo = Todo.query.filter_by(id=todo_id, user_id=current_user.id).first_or_404()
        
        args = todo_parser.parse_args()
        
        todo.title = args['title']
        todo.description = args.get('description', todo.description)
        todo.completed = args.get('completed', todo.completed)
        todo.priority = args.get('priority', todo.priority)
        
        if args.get('due_date'):
            todo.due_date = datetime.fromisoformat(args['due_date'])
        
        db.session.commit()
        
        return {
            'message': 'Todo updated successfully',
            'todo': todo.to_dict()
        }, 200
    
    @login_required
    def delete(self, todo_id):
        """Удалить задачу"""
        todo = Todo.query.filter_by(id=todo_id, user_id=current_user.id).first_or_404()
        
        db.session.delete(todo)
        db.session.commit()
        
        return {'message': 'Todo deleted successfully'}, 200

# Регистрация в API
api.add_resource(TodoListResource, '/todos')
api.add_resource(TodoResource, '/todos/<int:todo_id>')

Заключение

Flask предоставляет минималистичный, но мощный подход к веб-разработке. Ключевые преимущества:

  1. Гибкость — выбирайте только нужные компоненты
  2. Простота — быстрое начало разработки
  3. Расширяемость — богатая экосистема расширений
  4. Контроль — полный контроль над структурой приложения

Рекомендации для дальнейшего изучения:

  1. Официальная документация Flaskhttps://flask.palletsprojects.com/
  2. Flask Mega-Tutorial от Miguel Grinberg
  3. Flask Web Development (книга)
  4. Django vs Flask — выбор в зависимости от проекта
  5. Microservices с Flask — для масштабируемых приложений
  6. Asyncio с Flask — для высоконагруженных приложений

Лучшие практики:

  • Используйте Application Factory
  • Разделяйте приложение на Blueprints
  • Всегда используйте виртуальные окружения
  • Храните секреты в переменных окружения
  • Пишите тесты для критического функционала
  • Используйте миграции для изменения БД
  • Оптимизируйте запросы к базе данных
  • Настройте логирование для продакшена

Удачи в разработке на Flask!