Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 10 из 20 Средний 55 мин 150 XP

Проект: блог на Flask с постами и комментариями

Финальный проект курса: блог на Flask с постами, комментариями и пагинацией. Модели, CRUD-маршруты, наследование шаблонов, деплой на PythonAnywhere и разбор каждого решения.

Редакция Питоники

Резюме отправлено, вас позвали на собеседование и попросили показать живой проект. Ноутбуки и упражнения не в счёт: нужен адрес, по которому открывается настоящий сайт. Хорошая новость: у вас уже есть всё, чтобы собрать такой сайт за вечер, — девять уроков позади. Плохая тоже есть: по отдельности кусочки работают, а вместе их вы ещё не соединяли. Этим и займёмся: сделаем блог на Flask с постами и комментариями — классический первый проект, который не стыдно показать.

Проект небольшой, но честный: список постов с пагинацией, страница поста с комментариями, создание, правка и удаление записей, общий каркас шаблонов и в конце — деплой. Кода в этом уроке много: он и есть проект. Зато каждое решение снабжено объяснением, почему выбран именно этот путь. А шаблоны, как и в уроке про Jinja2, можно отрендерить прямо на странице — интерактив здесь даже важнее, потому что собирать вы всё будете сами.

Что собираем: карта блога

Начнём с карты, чтобы не заблудиться в собственном проекте. Весь блог — это пять маршрутов, у каждого понятная роль:

МаршрутМетодыЧто делает
/GETсписок постов, 5 на страницу, пагинация
/post/<int:post_id>GET, POSTпросмотр поста и форма комментария
/createGET, POSTформа нового поста
/post/<int:post_id>/editGET, POSTправка поста
/post/<int:post_id>/deletePOSTудаление поста

Заметьте, чего в таблице нет: регистрации, профиля, панели администратора. Это осознанное решение. Первый проект должен заработать целиком, а не на три четверти. Авторизацию, которую вы сделали в уроке 9, прикрутите следующим шагом — чек-лист в конце урока напомнит. Сейчас важнее провести одну запись по всему циклу жизни: создать, показать, отредактировать, удалить.

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

Блог помещается в два Python-файла и папку шаблонов — та самая структура из урока про статику и организацию проекта. Blueprint'ы здесь не нужны: шесть маршрутов не заслуживают отдельных модулей. Когда маршрутов станет двадцать — разделите, а пока честность важнее архитектуры:

Структура папок блога
myblog/
  app.py            # маршруты и конфигурация
  models.py         # модели Post и Comment
  seed.py           # разовое наполнение базы тестовыми данными
  requirements.txt  # flask, flask-sqlalchemy
  blog.db           # база SQLite, создаёт seed.py
  templates/
    base.html
    post_list.html
    post_detail.html
    post_form.html
    404.html
  static/
    style.css
Пять шаблонов: каркас и четыре страницы. 404.html пригодится, когда подключим обработчик ошибок в конце урока.

Модели Post и Comment: один-ко-многим

Сердце блога — две связанные модели. У одного поста много комментариев: это связь один-ко-многим, вы уже писали такие в уроке про базу данных. В базе её держит внешний ключ comment.post_id, а в коде — relationship, который даёт писать post.comments и comment.post, не вспоминая про SQL:

models.py
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()

class Post(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(120), nullable=False)
    body = db.Column(db.Text, nullable=False)
    created = db.Column(db.DateTime, default=datetime.now, index=True)

    comments = db.relationship(
        "Comment",
        backref="post",
        cascade="all, delete-orphan",
        order_by="Comment.created",
    )

    def __repr__(self):
        return f"<Post {self.id}: {self.title}>"

class Comment(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False)
    author = db.Column(db.String(60), nullable=False)
    text = db.Column(db.Text, nullable=False)
    created = db.Column(db.DateTime, default=datetime.now)
create_all создаст из этой схемы две таблицы: post и comment, связанные внешним ключом comment.post_id -> post.id.

Разбор по строчкам, потому что здесь спрятано три решения. Заголовок — String(120), а не Text: короткое поле честно ограничивает автора и не даёт вставить в него статью. Тело — Text без ограничения. Поле created с default=datetime.now заполняется само при вставке, а index=True — не украшение: главная страница сортирует посты по дате при каждом запросе, и индекс делает это быстро даже на десятках тысяч записей.

Почему cascade важен для комментариев

cascade="all, delete-orphan" — страховка от комментариев-сирот. Без него удаление поста оставляло бы в базе строки с мёртвым post_id: код не упадёт, но на странице поста №999 вдруг показались бы комментарии к несуществующей записи. С cascade SQLAlchemy удаляет комментарии вместе с постом одной операцией — а аргумент backref="post" в это же время даёт каждому комментарию ссылку на родителя, чтобы в шаблоне комментария можно было написать comment.post.title. Аргумент order_by выстраивает комментарии по времени без ручной сортировки в каждом маршруте.

Создаём таблицы и наполняем блог

Пустой блог не покажешь ни работодателю, ни самому себе. Наполним его отдельным скриптом seed.py — запустите один раз, и в базе появится несколько постов с комментариями. Обязательная деталь: всё обращение к базе — внутри with app.app_context(). Без контекста SQLAlchemy не знает, к какому приложению относится запрос, и падает с RuntimeError: Working outside of application context.

seed.py — тестовые данные
from app import app
from models import db, Post, Comment

with app.app_context():
    db.create_all()

    p1 = Post(title="Зачем я завёл блог", body="Первая запись: мотивация и план.")
    p2 = Post(title="Flask за выходные", body="Как я собрал первый сайт за два дня.")
    db.session.add_all([p1, p2])
    db.session.commit()

    db.session.add(Comment(post_id=p1.id, author="Марина", text="Жду продолжения!"))
    db.session.commit()

    print("Постов:", Post.query.count())
    print("Комментариев:", Comment.query.count())
    print(p1)
    print("Авторы комментариев:", [c.author for c in p1.comments])
Вывод
Постов: 2
Комментариев: 1
<Post 1: Зачем я завёл блог>
Авторы комментариев: ['Марина']
Запуск: python seed.py — и это единственный запуск. Если положить create_all прямо в app.py, таблицы будут проверяться при каждом старте сервера: не страшно, но бесполезно.

Последняя строка — маленький, но важный тест: p1.comments вернул список объектов Comment, значит relationship работает в обе стороны, и в шаблонах можно рассчитывать на пост.comments без ручных запросов.

Главная страница: список постов с пагинацией

Пока постов двенадцать, можно выводить все. Двести — уже нельзя: страница грузится вечность, читатель не доезжает до конца. Поэтому главная страница блога сразу делается с пагинацией. Вот каркас app.py вместе с первым маршрутом:

app.py — конфигурация и главная
from flask import Flask, render_template, request, redirect, url_for, flash
from models import db, Post, Comment

app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///blog.db"
app.config["SECRET_KEY"] = "dev-secret-key"
db.init_app(app)

POSTS_PER_PAGE = 5

@app.route("/")
def index():
    page = request.args.get("page", 1, type=int)
    pagination = Post.query.order_by(
        Post.created.desc()
    ).paginate(page=page, per_page=POSTS_PER_PAGE, error_out=False)
    return render_template("post_list.html", pagination=pagination)
Новое здесь только paginate() — всё остальное вы писали в предыдущих уроках: конфиг, init_app, request.args.

Строка request.args.get("page", 1, type=int) таит ловушку. Аргумент type=int не украшение: без него page будет строкой, и кто-то, открыв /?page=abc, получит ValueError: invalid literal for int() with base 10: 'abc' — и страницу с ошибкой вместо списка. С type=int Flask сам вернёт значение по умолчанию, то есть 1, для любого мусора в адресе. Я один раз ловил эту ошибку уже после выкладки сайта: в отладке я заходил по нормальным ссылкам, а роботы поисковика щупали всё подряд.

Что вернёт сервер (фрагмент /?page=1)
$ curl http://127.0.0.1:5000/
Вывод
  <header><a href="/">Все посты</a> | <a href="/create">Написать</a></header>
<article>
  <h2>Flask за выходные</h2>
  <p>Как я собрал первый сайт за два дня.</p>
</article>
<nav>
  <span>Стр. 1 из 3</span>
  <a href="/?page=2">Вперёд</a>
</nav>
Показан фрагмент страницы: над ним ещё каркас base.html, между постами — остальные статьи первой пятёрки. Посты идут от свежих к старым из-за Post.created.desc().

Объект pagination — это Pagination из Flask-SQLAlchemy. В шаблон уходит именно он, а не список постов: у объекта есть и посты текущей страницы, и данные для навигации. Самые нужные атрибуты:

АтрибутЧто содержит
pagination.itemsпосты текущей страницы — обычный список
pagination.page / pagination.pagesтекущая страница и всего страниц
pagination.totalвсего постов в базе
pagination.has_prev / has_nextесть ли предыдущая и следующая
pagination.prev_num / next_numих номера (None, если страницы нет)

Что выбрать: 404 или пустая страница

У paginate есть параметр error_out, и по умолчанию он True: запрос /?page=999 тогда отдаёт 404 — страницы ведь не существует. Я поставил error_out=False: на несуществующем номере страница просто отрисуется с пустым списком. Для блога я считаю это дружелюбнее — человек нажал на устаревшую ссылку, а ему молча показали главную без постов. Для API я бы выбрал наоборот: жёсткий 404 сразу говорит клиенту, что он ошибся адресом.

Страница поста: просмотр и приём комментариев

Один маршрут обслуживает два метода: GET рисует пост с комментариями, POST принимает новый комментарий. Вход в маршрут — db.get_or_404: если поста с таким id нет, посетитель получает честную страницу 404, а не ошибку базы:

app.py — страница поста с комментариями
@app.route("/post/<int:post_id>", methods=["GET", "POST"])
def post_detail(post_id):
    post = db.get_or_404(Post, post_id)
    if request.method == "POST":
        author = request.form.get("author", "").strip()
        text = request.form.get("text", "").strip()
        if not author or not text:
            flash("Заполните имя и текст комментария")
        else:
            comment = Comment(post_id=post.id, author=author, text=text)
            db.session.add(comment)
            db.session.commit()
            return redirect(url_for("post_detail", post_id=post.id))
    return render_template("post_detail.html", post=post)
strip() отрезает пробелы: десять пробелов вместо имени — это пустое имя, и валидация его отвергнет.

Валидация намеренно минимальная — два поля не пустые. Но она уже на месте, и это главное: пустые комментарии — первая атака на любой блог, раньше ботов приходят только они. Экранирование ввода берёт на себя Jinja2 с автоэкранированием: даже если кто-то впишет в имя script-тег, на странице он останется безобидным текстом — тот самый механизм, что мы разбирали на примере XSS в уроке про шаблоны. Главное — не открыть дыру фильтром |safe.

POST-redirect-GET: зачем редирект после формы

Посмотрите на return redirect(...): мы не рисуем страницу после POST, а отправляем браузеру код 302 и просим снова зайти по адресу поста, уже методом GET. Приём называется POST-redirect-GET, и без него обновление страницы (F5) отправляло бы комментарий повторно — браузер честно спрашивает «повторить отправку данных?». Проверьте ответ сервера сами:

Ответ сервера на отправку комментария
$ curl -i -X POST http://127.0.0.1:5000/post/1 -d "author=Марина" -d "text=Отличный пост!"
Вывод
HTTP/1.1 302 FOUND
Location: http://127.0.0.1:5000/post/1
Сокращённый вывод: полный ответ Werkzeug содержит ещё строки Server, Content-Type и тело. Суть — статус 302 и адрес, куда идти дальше.

После 302 браузер делает GET на тот же адрес — и видит страницу уже с новым комментарием. Флеш-сообщение «Заполните имя и текст» при этом показывается один раз и стирается из сессии, как мы разбирали в уроке про сессии и flash.

CRUD постов: создать, отредактировать, удалить

Три маршрута, и по привычному шаблону: GET показывает форму, POST сохраняет. Один шаблон post_form.html обслуживает и создание, и правку — в первом случае в него уходит post=None, во втором существующий пост:

app.py — создание, правка, удаление
@app.route("/create", methods=["GET", "POST"])
def create_post():
    if request.method == "POST":
        title = request.form.get("title", "").strip()
        body = request.form.get("body", "").strip()
        if not title or not body:
            flash("Нужны и заголовок, и текст поста")
        else:
            post = Post(title=title, body=body)
            db.session.add(post)
            db.session.commit()
            flash("Пост опубликован")
            return redirect(url_for("post_detail", post_id=post.id))
    return render_template("post_form.html", post=None)

@app.route("/post/<int:post_id>/edit", methods=["GET", "POST"])
def edit_post(post_id):
    post = db.get_or_404(Post, post_id)
    if request.method == "POST":
        title = request.form.get("title", "").strip()
        body = request.form.get("body", "").strip()
        if title:
            post.title = title
        if body:
            post.body = body
        db.session.commit()
        flash("Изменения сохранены")
        return redirect(url_for("post_detail", post_id=post.id))
    return render_template("post_form.html", post=post)

@app.route("/post/<int:post_id>/delete", methods=["POST"])
def delete_post(post_id):
    post = db.get_or_404(Post, post_id)
    db.session.delete(post)
    db.session.commit()
    flash("Пост удалён вместе с комментариями")
    return redirect(url_for("index"))
После commit у нового поста появляется id, сгенерированный базой, — поэтому redirect(url_for(..., post_id=post.id)) срабатывает уже в момент первого сохранения.

В edit_post пустое поле не затирает старое значение: пользователь мог заполнить только заголовок. Удаление — единственный маршрут без GET: он принимает только POST. Это не перестраховка, это шрам истории веба:

Шаблоны: живой рендер каркаса и списка

Теперь разметка. В Flask шаблоны берутся из папки templates, а их место в этом уроке занимает словарь — чтобы вы могли запустить рендер прямо здесь. Логика та же, что в уроке про наследование: каркас base.html, дочерний post_list.html заполняет его блоки. Запускайте:

Каркас и список постов (запустите)
from jinja2 import Environment, DictLoader

templates = {
    "base.html": """<!doctype html>
<html lang="ru">
<head>
  <title>{% block title %}Блог про Flask{% endblock %}</title>
</head>
<body>
  <header><a href="/">Все посты</a> | <a href="/create">Написать</a></header>
{% block content %}{% endblock %}
</body>
</html>""",
    "post_list.html": """{% extends "base.html" %}
{% block title %}Все посты{% endblock %}
{% block content %}
{% for post in pagination.items %}
<article>
  <h2>{{ post.title }}</h2>
  <p>{{ post.excerpt }}</p>
</article>
{% else %}
<p>Постов пока нет.</p>
{% endfor %}
{% endblock %}""",
}

env = Environment(loader=DictLoader(templates), trim_blocks=True, lstrip_blocks=True)

# имитация объекта Pagination из Flask-SQLAlchemy
class Pagination:
    def __init__(self, items):
        self.items = items

pagination = Pagination([
    {"title": "Зачем я завёл блог", "excerpt": "Первая запись: мотивация и план."},
    {"title": "Flask за выходные", "excerpt": "Как я собрал первый сайт."},
])

print(env.get_template("post_list.html").render(pagination=pagination))
Вывод
<!doctype html>
<html lang="ru">
<head>
  <title>Все посты</title>
</head>
<body>
  <header><a href="/">Все посты</a> | <a href="/create">Написать</a></header>
<article>
  <h2>Зачем я завёл блог</h2>
  <p>Первая запись: мотивация и план.</p>
</article>
<article>
  <h2>Flask за выходные</h2>
  <p>Как я собрал первый сайт.</p>
</article>
</body>
</html>

Класс Pagination имитирует реальный объект: шаблон читает pagination.items, ровно как будет во Flask. Почему имитация классом, а не словарём? У словаря в Python есть собственный метод .items, и Jinja2 вернул бы его вместо ключа — при подготовке этого урока я поймал на таком словаре TypeError: 'builtin_function_or_method' object is not iterable. Атрибуты объекта работают без сюрпризов. Тег {% else %} внутри цикла — запасной путь для пустого списка: вместо него отрисуется «Постов пока нет» — то самое поведение error_out=False при /?page=999. В render_template вы отправите настоящий Pagination, и шаблон не придётся менять ни на букву.

Карточка поста: дата и цитата (запустите)
from jinja2 import Template
from datetime import datetime

post = {
    "title": "Flask за выходные",
    "created": datetime(2025, 3, 14, 18, 30),
    "body": "В пятницу я решил собрать сайт. К вечеру воскресенья он работал.",
}

t = Template("""<article>
  <h2>{{ post.title }}</h2>
  <time>{{ post.created.strftime('%d.%m.%Y') }}</time>
  <p>{{ post.body[:60] }}{% if post.body | length > 60 %}...{% endif %}</p>
</article>""", trim_blocks=True, lstrip_blocks=True)
print(t.render(post=post))
Вывод
<article>
  <h2>Flask за выходные</h2>
  <time>14.03.2025</time>
  <p>В пятницу я решил собрать сайт. К вечеру воскресенья он рабо...</p>
</article>

Здесь два приёма, которые стоят того, чтобы их разглядеть. Первый: в шаблоне вызываются настоящие методы — strftime форматирует дату, срез [:60] отрезает цитату. Второй: посмотрите на вывод — текст обрезан посреди слова («рабо...»). Работает честно, но выглядит неопрятно. В настоящем проекте я делаю цитату свойством модели (post.excerpt), которое режет текст по границе слова ещё в Python, — правило то же, что и прежде: шаблон показывает, Python готовит.

Пагинация и комментарии в шаблоне

Навигация по страницам — обычные ссылки с ?page=N, собранные из атрибутов объекта pagination. Условия показывают ссылку только когда соседняя страница существует:

Навигация по страницам (запустите)
from jinja2 import Template

pagination = {"page": 2, "pages": 5, "has_prev": True, "prev_num": 1,
              "has_next": True, "next_num": 3}

t = Template("""<nav class="pagination">
{% if pagination.has_prev %}
<a href="/?page={{ pagination.prev_num }}">Назад</a>
{% endif %}
<span>Стр. {{ pagination.page }} из {{ pagination.pages }}</span>
{% if pagination.has_next %}
<a href="/?page={{ pagination.next_num }}">Вперёд</a>
{% endif %}
</nav>""", trim_blocks=True, lstrip_blocks=True)
print(t.render(pagination=pagination))
Вывод
<nav class="pagination">
<a href="/?page=1">Назад</a>
<span>Стр. 2 из 5</span>
<a href="/?page=3">Вперёд</a>
</nav>

На первой странице блок с «Назад» исчезнет целиком — has_prev равен False. Для десятка страниц этого достаточно; когда их станут сотни, пригодится метод iter_pages(), который рисует окно номеров вроде «1 2 3 … 12 13 14». И завершающий кусок — блок комментариев, где нужны сразу цикл, счётчик и заглушка:

Блок комментариев (запустите)
from jinja2 import Template

comments = [
    {"author": "Марина", "text": "Спасибо, собрала блог за вечер!"},
    {"author": "Кирилл", "text": "А как добавить лайки?"},
]

t = Template("""<h2>Комментарии ({{ comments | length }})</h2>
{% for c in comments %}
<div class="comment">
  <b>{{ c.author }}</b> пишет:
  <p>{{ c.text }}</p>
</div>
{% else %}
<p>Пока тишина. Будьте первым!</p>
{% endfor %}""", trim_blocks=True, lstrip_blocks=True)

print("--- есть комментарии ---")
print(t.render(comments=comments))
print("--- пустой список ---")
print(t.render(comments=[]))
Вывод
--- есть комментарии ---
<h2>Комментарии (2)</h2>
<div class="comment">
  <b>Марина</b> пишет:
  <p>Спасибо, собрала блог за вечер!</p>
</div>
<div class="comment">
  <b>Кирилл</b> пишет:
  <p>А как добавить лайки?</p>
</div>
--- пустой список ---
<h2>Комментарии (0)</h2>
<p>Пока тишина. Будьте первым!</p>

В реальном post_detail.html вы передадите в шаблон объект поста и будете ходить по post.comments — тот самый relationship из models.py. Счётчик в заголовке даёт фильтр | length. А фраза «Будьте первым» — это не украшение: приглашение под пустым блоком заметно повышает долю первых комментариев, психология работает и в вебе.

Как задеплоить Flask на PythonAnywhere?

Деплой на PythonAnywhere словами

Сайт на 127.0.0.1 виден только вам. Бесплатный и самый простой путь для Flask — PythonAnywhere: облачный хостинг, где на бесплатном тарифе живёт один небольшой сайт по адресу имя.pythonanywhere.com. Никаких серверов и Docker на этом этапе не нужно. Порядок действий такой:

  1. Заведите аккаунт и откройте вкладку Console — вам понадобится Bash-консоль.
  2. Загрузите проект: git clone вашего репозитория или загрузка файлов через интерфейс Files.
  3. В консоли создайте виртуальное окружение: mkvirtualenv blogenv, затем pip install flask flask-sqlalchemy.
  4. Вкладка Web — Add new web app — Manual configuration — укажите это окружение в разделе Virtualenv.
  5. Откройте WSGI-файл (ссылка есть на вкладке Web) и впишите в него свой проект — см. код ниже.
  6. Нажмите Reload и откройте свой адрес. Сайт живой.
WSGI-файл PythonAnywhere (сокращённо)
import sys

path = "/home/ivanov/myblog"
if path not in sys.path:
    sys.path.insert(0, path)

from app import app as application
Имя application обязательно — на него смотрит сервер. Путь /home/ivanov/myblog замените на свой: username можно подсмотреть в адресной строке консоли.

Чек-лист готовности сайта и что дальше

Прежде чем делиться ссылкой, пройдитесь по списку. Каждый пункт — то, что отличает «работает у меня» от «работает у всех»:

app.py — обработчик 404
@app.errorhandler(404)
def page_not_found(e):
    return render_template("404.html"), 404
Теперь db.get_or_404 и неверные адреса показывают аккуратную страницу вашего блога, а не стандартный листинг Werkzeug.
  1. debug=False, SECRET_KEY из переменной окружения — он не в репозитории.
  2. requirements.txt зафиксирован: pip freeze > requirements.txt.
  3. Путь к базе абсолютный, у blog.db настроен бэкап — это единственный файл с вашими данными.
  4. Обработчики 404 и 500 показывают страницы в дизайне блога.
  5. У каждой формы есть валидация и флеш-сообщения, удаление — только по POST.
  6. Никаких |safe на пользовательском вводе — автоэкранирование работает.
  7. Вход и регистрация подключены (урок 9), посты привязаны к авторам.
  8. На страницах заполнены title и meta description, есть favicon.
  9. Сайт проверен с телефона: шапка, формы, пагинация, комментарии.

Курс собран в этот чек-лист целиком: маршруты из второго урока, шаблоны из третьего, формы из четвёртого, база из пятого, сессии из шестого, структура из седьмого. Блог из этого урока — не конец, а фундамент: добавьте теги к постам (связь многие-ко-многим, следующая ступень после один-ко-многим), ленту по авторам, а потом прикрутите авторизацию из урока девять и получите полноценный многопользовательский блог.

А если после блога захочется другой стороны Python-веба — не страниц, а API — идите в первый урок FastAPI: вы удивитесь, сколько понятий из этого курса вам уже знакомо. Курс пройден: самоучитель Flask свернул десять уроков в один работающий блог — держите его как шпаргалку, когда забудется, где ставится кука, а где session.pop. Мой практический совет напоследок: не останавливайтесь на моём коде. Сломайте блог намеренно — удалите commit, откройте /?page=abc, отправьте пустую форму — и посмотрите, как проект реагирует. Настоящее понимание начинается с починки.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

from jinja2 import Template

t = Template("{% for c in comments %}{{ c.author }};{% else %}пока тишина{% endfor %}")
print(t.render(comments=[]))
print(t.render(comments=[{"author": "Марина"}]))
from jinja2 import Template

p = {"page": 2, "pages": 3}

t = Template("{{ p.page }} из {{ p.pages }}{% if p.page < p.pages %} (есть ещё){% endif %}")
print(t.render(p=p))
Проверь себя
0 / 5

1. Что делает cascade="all, delete-orphan" в relationship между Post и Comment?

2. Почему в request.args.get("page", 1, type=int) важен аргумент type=int?

3. Что вернёт db.get_or_404(Post, 999), если поста с id=999 нет?

4. Зачем после успешного POST возвращают redirect(url_for(...))?

5. Что покажет главная страница при error_out=False и адресе /?page=999, если всего 3 страницы?

Карточки терминов
Запомнено: 0 / 6
Практика

Соберите шаблон списка постов для главной блога. Для каждого поста — строка вида <li>Заголовок - N комм.</li>: заголовок выведите фильтром capitalize, а количество комментариев возьмите из поля comments. Если комментариев ноль — вместо числа напишите «пока тихо». Весь список обёрнут в <ul>...</ul>. Флаги trim_blocks и lstrip_blocks уже включены, поэтому теги Jinja2 не оставят лишних пустых строк.

practice.py
Вопросы и ответы по уроку

Нужны ли Blueprint для небольшого проекта на Flask?

Нет: блог из этого урока живёт в двух Python-файлах, и шести маршрутам отдельные модули не нужны. Блюпринты окупаются, когда маршрутов становится двадцать, имена функций конфликтуют или появился второй смысловой блок — сам механизм разбирался в уроке про Blueprint.

Как задеплоить Flask-приложение на PythonAnywhere?

Загрузите файлы проекта, создайте виртуальное окружение с flask и flask-sqlalchemy, затем во вкладке Web добавьте веб-приложение с ручной настройкой и укажите это окружение. В WSGI-файле пропишите путь к проекту в sys.path и импортируйте приложение как application: from app import app as application. После Reload сайт откроется по адресу имя.pythonanywhere.com.

Как сделать пагинацию во Flask?

У Flask-SQLAlchemy есть метод paginate: Post.query.order_by(Post.created.desc()).paginate(page=page, per_page=5, error_out=False). В шаблон передают объект pagination: pagination.items — записи текущей страницы, а has_prev, prev_num, has_next, next_num собирают ссылки «Назад» и «Вперёд» вида /?page=2.

Что делает cascade в модели SQLAlchemy?

cascade="all, delete-orphan" в relationship означает: при удалении родителя (поста) автоматически удаляются все дочерние записи (комментарии). Без этого остаются строки-сироты с битым внешним ключом post_id, которые потом всплывают багами на страницах.

Какой проект на Flask подойдёт в портфолио новичку?

Блог с постами и комментариями: он покрывает CRUD, формы, базу данных, шаблоны и деплой — и при этом понятен работодателю без объяснений. Дополните его авторизацией и выложите на PythonAnywhere: живая ссылка на работающий сайт ценится выше перечисления технологий в резюме.

Понравился урок? Сошлитесь на него

«Первый проект должен заработать целиком, а не на три четверти.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.

Проверьте знания по Flask

В челлендже — 20 задач по Flask, по 2 из каждого урока этого раздела. Формат: фрагмент кода и четыре варианта — что напечатает. После ответа — вердикт и объяснение со ссылкой на урок-источник.

Тест по Flask: 20 задач