Приключения Вупсеня, Пупсеня и Павла Дурова: экспресс курс по фласку

Введение

Flask - это простой и удобный фреимворк для создания веб-сервисов на питоне.

На первом уровне погружения он похож на telebot. Задача телеграм бота - отвечать на сообщения, поэтому там мы привязывали функции к разным типа сообщений. Эти функции вызывались в ответ на сообщения пользователей и генерировали ответ.

Задача веб-сервиса (серверной его части) - отдавать пользователям html страницы в ответ на запросы. У каждой страницы есть свой адрес, поэтому flask позволяет привязывать функции к адресам страниц на сайте. Когда пользователь передет по адресу, вызовется соотвествующая функция, которая отправит ему html код.

Для удобства выполнения задач лучше всего клонировать репозиторий: https://github.com/roctbb/goto-web-course

Создаем обработчики запросов

Полный адрес страницы состоит из адреса сервера и адреса конкретной страницы на нем. Например, в адресе https://luntik.ru/pages/vupsen:

  • https - это протокол, он сообщает браузеру, что мы хотим именно сайт (на самом деле есть нюансы, но это потом);
  • luntik.ru - имя хоста, по нему браузер понимает, на какой сервер нужно послать запрос;
  • /pages/vupsen - путь (route) к конкретной странице на сайте.

Фласк позволяет привязывать функции именно к адресам страницы (чтобы потом сайт можно было запускать на разных серверах). Такие функции называются обработчиками или хэндлерами (request handlers).Пример!

server.py

from flask import Flask

app = Flask(__name__)

# функция вызовется, когда пользователь в браузере наберет АДРЕСХОСТА/ (зайдет на главную страницу сайта)
@app.route('/')
def index():
    # возвращаем HTML код, который увидит пользователь
    return """
        <html>
            <head>
                <title>Вупсень или пупсень?</title>
            </head>
            <body>
                <h1>Научись отличать Вупсеня от Пупсеня!</h1>
                <ul>
                    <li><a href="/vupsen">Это Вупсень!</a></li>
                    <li><a href="/pupsen">Это Пупсень!</a></li>
                </ul>
            </body>
        </html>
    """

# TODO: Сделай так, чтобы на странице выводилась картинка с Вупсенем из интернета
# PS. Для этого нужен тэг img: http://htmlbook.ru/html/img
@app.route('/vupsen')
def vupsen_page():
    pass

# TODO: Здесь добавь страницу /pupsen, на которой будет картинка с пупсенем

app.run(debug=True)

В примере выше с помощью # TODO помечены части, которые нужно дописать. В частности, сейчас страница про Вупсеня ничего не возвращает, а страницы про Пупсеня нет вовсе!

Используем шаблоны для подстановки данных

В первом примере в одном файле у нас смешался html и код на python. С большими сайтами это очень неудобно - код неудобно редактировать, непонятно, что где, а файл становится гигантским. Поэтому на практике html код отделяют от кода на питоне в отдельные файлы.

Но как тогда подставлять какие нибудь значения внутрь html кода? Например, передавать переменные? Это задачу решают шаблонизаторы - это библиотеки, которые умеют подставлять нужные данные в текст и даже выполнять в нем несложные условия и циклы.

Вместе с фласком используется шаблонизатор Jinja 2. Чтобы создать шаблон - просто положи html файл в подпапку templates рядом со скриптом сервера.

С этого момента наш сервер будет состоять из нескольких файлов. Внимательно следи за названиями и папками, или просто клонируй репозиторий.

server.py

import random
from flask import Flask, render_template

app = Flask(__name__)

@app.route('/')
def index():
    price = random.randint(30, 130)

    # Чтобы не смешивать код на питоне и HTML, можно вынести HTML страницу в отдельный файл - шаблон (template)
    # Шаблоны должны храниться в подпапке templates
    # Фишка шаблонов - возможность подставлять внутрь них переменные с помощью кода {{ имя переменной }} внутри шаблона
    # Здесь мы просим фласк "отрендерить шаблон" index.html и передаем туда переменную price (посмотри в файле index.html, как она вставляется)
    return render_template('index.html', price=price)

app.run(debug=True)

templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Курс доллара</title>
</head>
<body>

<h4 style="text-align: center;">Курс доллара сегодня:</h4>

<!-- с помощью фигурных скобочек мы говорим фласку, что в это месту нужно подставить значение переменной price -->
<h3 style="text-align: center;">{{ price }} (руб)</h3>

</body>
</html>

Циклы в шаблонах

Удобство шаблонизатора, что он позволяет не только вставлять отдельные переменные, но и писать несложные циклы, чтобы подставлять в страницу целый список. Ниже небольшой пример со списком упражнений от Павла Дурова.

server.py

from flask import Flask, render_template

tasks = [
    "Пресс качат",
    "Бегит",
    "Турник",
    "Анжуманя",
    "Гантели"
]

app = Flask(__name__)

@app.route('/')
def index():
    # В шаблон можно передавать не только строки и числа, но и список целиком
    return render_template("tasks.html", tasks=tasks)

app.run(debug=True)

templates/tasks.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>
    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;">
    <img style="height: 300px;" src="https://investnews.com.br/wp-content/uploads/2022/03/Pavel-Durov-3x4-2-640x853.png"/>
</p>

<h3 style="text-align: center;">Список дел</h3>

<!-- В шаблон можно не только подставить переменные, но и использовать циклы для перебора элементов списка -->
<ol class="center">
    {% for task in tasks %}
    <li>{{ task }}</li>
    {% endfor %}
</ol>

</body>
</html>

Условия в шаблонах и статические файлы

Картинка в прошлом примере подгружалась из интернета. А что если мы хотим загрузить ее на наш сайт? Для этого можно создать папку static и просто закинуть туда любые файл, которые мы хотим свободно разместить на нашем сайте.

А еще в примере ниже мы будем зачеркивать уже сделанные дела с помощью условий в Jinja.

Файл durov.png кладем в папку static.

server.py

from flask import Flask, render_template

# Чтобы отображать, выполнена ли задача, усложним структуру данных
tasks = [
    {
        "name": "Пресс качат",
        "is_done": True
    },
    {
        "name": "Бегит",
        "is_done": False
    },
    {
        "name": "Турник",
        "is_done": True
    },
    {
        "name": "Анжуманя",
        "is_done": False
    },
    {
        "name": "Гантели",
        "is_done": False
    },
]

app = Flask(__name__)

@app.route('/')
def index():
    # И все так же можем передать ее в шаблон
    return render_template("tasks.html", tasks=tasks)

app.run(debug=True)

templates/tasks.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>
    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;">
    <!-- Чтобы не подгружать картинки с других сайтов, можем хранить ее у себя, положив в папку static (из других папок работать не будет) -->
    <img style="height: 300px;" src="/static/durov.png"/>
</p>

<h3 style="text-align: center;">Список дел</h3>

<!-- Можно не только перебирать списки, но и образаться к словарям, а еще использовать условные операторы
     Подробнее про шаблоны во Flask: https://jinja.palletsprojects.com/en/3.1.x/
 -->
<ol class="center">
    {% for task in tasks %}
        {% if task['is_done'] %}
            <li><strike>{{ task['name'] }}</strike></li>
        {% else %}
            <li>{{ task['name'] }}</li>
        {% endif %}
    {% endfor %}
</ol>

</body>
</html>

Подстановка параметров в пути

В примере с Вупсенем и Пупсенем мы создали отдельные страницы для каждого персонажа. Но часто таких страниц бывает много, либо их количество неограничено и растет со временем.

В таком случае можно создать одну функцию для нескольких страниц и передавать в нее параметр - что именно надо показать.

Ниже мы рассмотрим пример сайта с новостями. На главной странице будут показываться только заголовки, а при клике на заголовок открывается страница с полным текстом новости.

У главной страницы будет адрес /, а у страниц конкретных постов будут адреса вида /news/123, где 123 - номер новости. Ниже пример, как такое сделать. Обрати внимание на параметр в декораторе пути.

server.py

from flask import Flask, render_template, abort

app = Flask(__name__)

news = [
    {
        "name": "Из-за глобального потепления сутки на Земле увеличатся до 25 часов",
        "text": "Французские и немецкие экологи в авторитетном научном журнале Nature опубликовали модель изменения климата до 2050 года. Согласно полученным данным, если существующие темпы глобального потепления сохранятся, то уже к 2040 году земные сутки станут длиннее на 1 час. ",
    },
    {
        "name": "Эксперт: марсианская цивилизация погибла из-за эпидемии коронавируса",
        "text": "Известный уфолог Роберт Фляйшер опубликовал в журнале Exopolitik Deutschland статью об обстоятельствах, приведших к гибели разумной жизни на Марсе. Специалисту удалось доказать, что это произошло из-за масштабной эпидемии коронавирусной инфекции, которая случилась 5 тысяч лет назад.  ”Моя команда проанализировала огромный пласт археологических объектов, документов и письменных источников древних цивилизаций. Давно доказано, что египетские пирамиды были построены инопланетянами, но сейчас нам наконец-то удалось расшифровать одну из надписей на гробнице фараона Хефрена. Она однозначно подтверждает, что марсиане погибли из-за коронавируса”, - отметил Фляйшер. ",
    },
    {
        "name": "WP: NASA закупило в марте рекордное количество батутов",
        "text": "Более 2 тысяч батутов было закуплено NASA в марте этого года у крупнейшего в США производителя изделий Trampoline technologies, пишет The Washington Post. По мнению автора материала, решение могло быть принято в связи с прекращением поставок из России двигателей РД-180 и двигателе РД-181. ",
    },
    {
        "name": "Российские синицы массово отказываются есть сало",
        "text": "Орнитологи бьют тревогу: представители вида Cyanistes caeruleus (синица синяя – ред.), обитающие на территории России, массово отказались от употребления в пищу свиного сала, которым их подкармливают энтузиасты. У большинства птиц из-за смены рациона поменялась окраска.",
    },
]

@app.route('/')
def index():
    return render_template('index.html', news=news)

# Чтобы посмотреть конкретную новость, будем передавать ее номер в списке после адреса, вот так /news/НОМЕР
# С помощью <int:id> мы показываем фласку, что ожидаем в этом месте число id, он сам его увидит и передаст в качестве параметра в функцию
@app.route('/news/<int:id>')
def news_page(id):
    # Проверяем, что новость существует
    if id < 0 or id >= len(news):
        abort(404)

    # Передаем нужную новость в шаблон
    return render_template('news.html', topic=news[id])

app.run(debug=True)

templates/index.html

P.S. В Jinja нельзя написать range(len(news)), поэтому чтобы получить порядковый номер итерации цикла можно использовать{{ loop.index0 }}. Если мы используем цикл for topic in news, то в loop.index0 будет номер текущего элемента списка.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Актуальные новости</title>
</head>
<body>

<h1>Актуально сегодня...</h1>

<ul>
    <!-- Перебираем все новости и выводим их заголовки -->
    {% for topic in news %}
        <!-- Чтобы узнать порядковый номер элемента списка в цикла, используем специальную переменную loop.index0 -->
        <!-- target="_blank" нужен чтобы страница открывалась в новой вкладке -->
        <li><a href="/news/{{ loop.index0 }}" target="_blank">{{ topic['name'] }}</a></li>
    {% endfor %}
</ul>
</body>
</html>

templates/news.html


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Новости: {{ topic['name'] }}</title>
</head>
<body>

<a href="/">Все новости</a>

<h2>{{ topic['name'] }}</h2>

<p>{{ topic['text'] }}</p>

</body>
</html>```

Изменяем состояние данных по запросу пользователя

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

Давайте это исправим! В прошлом примере мы научились создавать обработчик для любого числа адресов. Эту идею мы можем использовать, чтобы создать страницы, переходя на которые задача помечается сделанной (или, наоборот, несделанной). Посколько задача таких страниц только изменить состояние данных, то возвращать они будут не html код, а редирект - просьбу для браузера перейти на другую страницу (т.е. на список дел, например).

Итого, у нас будет три обработчика:

  • / - список дел;
  • /tasks/123/done - помечает задачу 123 выполненной;
  • /tasks/123/undone - помечает задачу 123 невыполненной.

Ссылки на эти страницы для каждого дела поместим в шаблон списка дел.

server.py


from flask import Flask, render_template, redirect, abort

tasks = [
    {
        "name": "Пресс качат",
        "is_done": True
    },
    {
        "name": "Бегит",
        "is_done": False
    },
    {
        "name": "Турник",
        "is_done": True
    },
    {
        "name": "Анжуманя",
        "is_done": False
    },
    {
        "name": "Гантели",
        "is_done": False
    },
]

app = Flask(__name__)

# Эта страница показывает список задач
@app.route('/')
def index():
    # Чтобы показать прогресс бар с кол-вом выполненых задач, передадим в шаблон, сколько сделано и сколько их всего
    done_count = len([task for task in tasks if task['is_done']])
    count = len(tasks)
    return render_template("tasks.html", tasks=tasks, done_count=done_count, count=count)

# Чтобы пометить, что задача выполена, сделаем страницу /tasks/НОМЕРЗАДАЧИ/done
@app.route('/tasks/<int:id>/done')
def make_done(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = True

    # Поскольку у нас уже есть страница с отображением списка задач, можем просто перенаправить туда пользователя
    return redirect('/')

# Эта страница наоборот отмечачет задачу невыполенной
@app.route('/tasks/<int:id>/undone')
def make_undone(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = False

    return redirect('/')

app.run(debug=True)

templates/tasks.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>

    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;"><img style="height: 300px;" src="/static/durov.png"/>
</p>

<h3 style="text-align: center;">Список дел</h3>

<!-- Шкала с процентом выполенных задач -->
<p class="center"><progress value="{{ done_count }}" max="{{ count }}">60%</progress></p>

<ol class="center">
    {% for task in tasks %}
        {% if task['is_done'] %}
            <!-- Показываем ссылку на страницу, которая отметит задачу невыполенной -->
            <li><strike>{{ task['name'] }}</strike> <small><a href="/tasks/{{ loop.index0 }}/undone">Не выполнено</a></small>
            </li>
        {% else %}
            <!-- Показываем ссылку на страницу, которая отметит задачу выполенной -->
            <li>{{ task['name'] }} <small><a href="/tasks/{{ loop.index0 }}/done">Выполнено</a></small></li>
        {% endif %}
    {% endfor %}
</ol>

</body>
</html>

Используем формы для добавления данных

А теперь самое крутое. В нашем списке дел пока нельзя добавлять новые, самое время это исправить!

Когда браузер что-то хочет от сервера, он отправляет серверу запрос (это мы уже знаем и умеем вешать обработчики на эти запросы). Но эти запросы бывают разных типов (или, по-другому, в протоколе http есть разные методы (methods)).

Когда браузер хочет получить данные от сервера, он отправляет запрос методом GET. Когда мы кликаем по ссылке или сами вбиваем адрес в адресной строке, отправляется именно GET-запрос. По-умолчанию, обработчики во фласке тоже срабатывают именно на GET запросы.

Но если мы хотим что то отправить на сервер, то нужно отправить POST запрос. Самый простой способ это сделать - заполнить форму (<form method="POST">) и нажать на кнопку. Тогда все введенные данные улетят на сервер, где можно поймать их с помощью обработчика POST запроса (@app.route('/', methods=['POST'])),

Внимательно изучи этот пример на гитхабе или ниже.

templates/tasks.html

Начнем с шаблона добавления дела. В нем мы используем тэг <form method="POST" action="/tasks/add">, чтобы создавать форму, а в ней <input> для полей ввода. Атрибут method меняет тип запроса, а атрибут action говорит браузеру, куда отправить запрос с введенными данными, когда пользователь нажнет на кнопку.

В тэге <input> самое важное - атрибут name. Все введенные пользователем данные попадут на сервер в виде словаря, где значения атрибутов name будут ключами.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>

    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;"><img style="height: 300px;" src="/static/durov.png"/>
</p>

<h3 style="text-align: center;">Список дел</h3>

<p class="center"><progress value="{{ done_count}}" max="{{ count }}">60%</progress></p>

<ol class="center">
    {% for task in tasks %}
        {% if task['is_done'] %}
            <li><strike>{{ task['name'] }}</strike> <small><a href="/tasks/{{ loop.index0 }}/undone">Не выполнено</a></small>
            </li>
        {% else %}
            <li>{{ task['name'] }} <small><a href="/tasks/{{ loop.index0 }}/done">Выполнено</a></small></li>
        {% endif %}
    {% endfor %}
</ol>

<!-- Чтобы пользователь мог создать новую задачу, добавим форму для ввода данных -->
<!-- После нажатии кнопки (button) внутри формы, браузер отправить запрос типа method на страницу action -->
<!-- Все что ввел пользователь будет приложено к запросу в виде параметров -->
<form action="/tasks/add" method="post">
    <p class="center">
        <!-- Атрибут name указывает на имя параметра, именно по нему можно будет достать то, что ввел пользователь в это поле во фласке. -->
        <input name="task_name" type="text" />
        <button>Добавить</button>

    </p>
</form>

</body>
</html>

server.py

Теперь посмотрим на бекенд. Здесь добавилась функция add_task с декоратором @app.route('/tasks/add', methods=['post']). Внутри нее все данные, которые пользователь ввел в форму (нажал на кнопку, отправился POST запрос и мы попали в функцию) будут лежать в словаре request.form.

from flask import Flask, render_template, redirect, abort, request

tasks = [
    {
        "name": "Пресс качат",
        "is_done": True
    },
    {
        "name": "Бегит",
        "is_done": False
    },
    {
        "name": "Турник",
        "is_done": True
    },
    {
        "name": "Анжуманя",
        "is_done": False
    },
    {
        "name": "Гантели",
        "is_done": False
    },
]

app = Flask(__name__)

@app.route('/')
def index():
    done_count = len([task for task in tasks if task['is_done']])
    count = len(tasks)
    return render_template("tasks.html", tasks=tasks, done_count=done_count, count=count)

@app.route('/tasks/<int:id>/done')
def make_done(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = True

    return redirect('/')

@app.route('/tasks/<int:id>/undone')
def make_undone(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = False

    return redirect('/')

# Страница, задача которой - получить данные от пользователя и добавить новую задачу в список
# Тк она реагирует на POST запрос (запрос с параметрами), добавляем methods=['post']
# По-умолчанию фласк считает, что methods=['get']
@app.route('/tasks/add', methods=['post'])
def add_task():
    # Чтобы получить приложенные к запросы параметры, используем словарь request.form
    # В отличие от request.form['task_name'], метод get позволяет нам не получить ошибку, если параметра в запросе нет (он вернет None)
    task_name = request.form.get('task_name')

    # Если имя задачи не пусто
    if task_name:
        tasks.append({
            "name": task_name,
            "is_done": False
        })

    return redirect('/')

app.run(debug=True)

Используем формы для редактирования данных

В прошлом примере мы добавили возможность добавления новых дел в список. Теперь давайте добавим возможность редактировать уже добавленные дела и удалять их.

Нам понадобится страница с формой редактирования дела - /tasks/<int:id>/edit. Если на такой адрес пришел GET запрос, мы возвращаем форму. А вот если пришел POST запрос на тот же адрес - вносим изменения и возвращаем редирект на список дел.

Для удаления дела - /tasks/<int:id>/delete.

server.py

from flask import Flask, render_template, redirect, abort, request

# Пойдем еще дальше, сделаем полноценный проект - добавим задачам описание, а еще позволим их редактировать и удалять
# Будем следовать логике REST: одно действие с одним типом объектов (у нас только один тип объекта - задачи) - одна страница (handler, обработчик).

tasks = [
    {
        "name": "Пресс качат",
        "description": "3 падхода 20 раз",
        "is_done": True
    },
    {
        "name": "Бегит",
        "description": "3 камэ",
        "is_done": False
    },
    {
        "name": "Турник",
        "description": "5 патходав 10 раз",
        "is_done": True
    },
    {
        "name": "Анжуманя",
        "description": "По ощущеня",
        "is_done": False
    },
    {
        "name": "Гантели",
        "description": "Бицеп, трицеп, плеча",
        "is_done": False
    },
]

app = Flask(__name__)

# Страница со списком задач
@app.route('/')
def index():
    done_count = len([task for task in tasks if task['is_done']])
    count = len(tasks)
    return render_template("tasks.html", tasks=tasks, done_count=done_count, count=count)

# Страница, которая помечает задачу сделанной
@app.route('/tasks/<int:id>/done')
def make_done(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = True

    return redirect('/')

# Страница, которая помечает задачу не сделанной
@app.route('/tasks/<int:id>/undone')
def make_undone(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    tasks[id]["is_done"] = False

    return redirect('/')

# Страница, которая удаляет задачу
@app.route('/tasks/<int:id>/delete')
def delete_task(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    del tasks[id]

    return redirect('/')

# Страница, которая добавляет задачу
@app.route('/tasks/add', methods=['post'])
def add_task():
    task_name = request.form.get('task_name')
    task_description = request.form.get('task_description', '')

    if task_name:
        tasks.append({
            "name": task_name,
            "is_done": False,
            "description": task_description
        })

    return redirect('/')

# Страница, которая показывает (!) форму изменения задачи
@app.route('/tasks/<int:id>/edit')
def edit_task_page(id):
    if id < 0 or id >= len(tasks):
        abort(404)
    # Передаем в шалон текущие параметры задачи
    return render_template('edit.html', task=tasks[id], task_id=id)

# Страница, которая редактирует (!) зададу, адрес тот же, но метод POST (!)
@app.route('/tasks/<int:id>/edit', methods=['post'])
def edit_task(id):
    if id < 0 or id >= len(tasks):
        abort(404)

    task_name = request.form.get('task_name')
    task_description = request.form.get('task_description', '')

    if task_name:
        tasks[id]['name'] = task_name
        tasks[id]['description'] = task_description

    return redirect('/')

app.run(debug=True)

templates/tasks.html

Здесь не забываем добавить ссылки на страницы удаления и редактирования.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>

    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;"><img style="height: 300px;" src="/static/durov.png"/>
</p>

<h3 style="text-align: center;">Список дел</h3>

<p class="center"><progress value="{{ done_count}}" max="{{ count }}">60%</progress></p>

<ol class="center">
    {% for task in tasks %}
        {% if task['is_done'] %}
            <li><strike>{{ task['name'] }}</strike> <small><a href="/tasks/{{ loop.index0 }}/undone">Не выполнено</a> <a href="/tasks/{{ loop.index0 }}/edit">Изменить</a> <a href="/tasks/{{ loop.index0 }}/delete">Удалить</a></small><br>
                <small>{{ task['description'] }}</small>
            </li>
        {% else %}
            <li>{{ task['name'] }} <small><a href="/tasks/{{ loop.index0 }}/done">Выполнено</a> <a href="/tasks/{{ loop.index0 }}/edit">Изменить</a> <a href="/tasks/{{ loop.index0 }}/delete">Удалить</a></small><br>
            <small>{{ task['description'] }}</small></li>
        {% endif %}
    {% endfor %}
</ol>

<form action="/tasks/add" method="post">
    <p class="center"><input name="task_name" type="text" /><button>Добавить</button></p>
    <p class="center"><textarea name="task_description" placeholder="Описание"></textarea></p>
</form>

</body>
</html>

templates/edit.html

А тут форма для редактирования дела. Обрати внимание, что мы подставляем текущий текст и описание задачи в поля, чтобы их можно было менять, а не переписывать с нуля.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Задания для спортсмена</title>

    <style>
        .center {
            text-align: center;
            list-style-position: inside;
            padding-inline-start: 0;
        }
    </style>
</head>
<body>

<p style="text-align: center;"><img style="height: 300px;" src="/static/durov.png"/>
</p>

<h3 style="text-align: center;">Изменение задачи</h3>

<form action="/tasks/{{ task_id }}/edit" method="post">
    <p class="center"><input name="task_name" type="text" value="{{ task['name'] }}" /><button>Сохранить</button></p>
    <p class="center"><textarea name="task_description" placeholder="Описание">{{ task['description'] }}</textarea></p>
</form>

</body>
</html>