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

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

Чаще всего API используется в привычных нам сайтах. Другими словами, API чаще всего пишут для того, чтоб прикрутить его к визуальной части к фронтенду

Фронтенд на простых примерах

Фронтенд представляет собой часть веб-приложения, которая отвечает за визуальное отображение и взаимодействие с пользователем. Важной особенностью фронтенда является то, что он не имеет прямого доступа к базе данных. Вместо этого он взаимодействует с бэкендом через API. Рассмотрим это на простом примере:

  1. Пользователь заполняет форму анкеты, содержащую поля: имя, фамилия, возраст и пол.

  2. После заполнения формы и нажатия кнопки «Отправить данные», данные отправляются на сервер с помощью POST‑запроса через JavaScript.

  3. Бэкенд обрабатывает полученные данные, сохраняет их в базе данных и возвращает ответ, например, в формате JSON {«status»: «ok»}.

  4. Фронтенд получает ответ и отображает соответствующее сообщение пользователю, либо перенаправляет его на другую страницу.

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

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

Из этого следует, что фронтенд и бэкенд могут существовать независимо, взаимодействуя друг с другом через API, и могут быть развернуты на одном сервере или на разных серверах.

Подходы к разработке фронтенда

Тут существует несколько подходов, но, при любом раскладе, каждый из подходов будет подразумевать использование HTML, CSS и JavaScript в том или ином виде (в этом списке, к сожалению, мы не видим Python, но раньше времени не расстраивайтесь).

Далее мы не будем рассматривать такие решения, как WordPress, Opencart, Tilda и прочее. Мы тут про другое — про создание веб приложений с нуля.

В данном случае можно использовать:

  • Чистый HTML, CSS и JavaScript. Если проект имеет простую структуру и не требует сложной логики, можно создать веб‑страницу с нуля, используя только базовые технологии. Такой подход подходит для быстрого прототипирования и небольших проектов.

  • Фронтенд‑фреймворки. Современные фреймворки, такие как Angular, React и Vue3, позволяют создавать динамичные и реактивные веб‑приложения. Эти инструменты помогают быстро разрабатывать сложные интерфейсы и обеспечивают высокую производительность.

  • Шаблонизаторы, такие как Jinja2. Мы будем уделять особое внимание этому подходу, так как он идеально подходит для интеграции с FastAPI. Шаблонизаторы позволяют динамически генерировать HTML‑страницы на основе данных, предоставляемых сервером, минимизируя использование JavaScript.

Шаблонизатор Jinja2

Обычные HTML-страницы статичны и требуют JavaScript для динамического обновления содержимого. В этом случае шаблонизаторы приходят на помощь, предоставляя механизм для генерации динамических страниц.

Один из самых популярных шаблонизаторов для Python — Jinja2, который используется в таких фреймворках, как Django, Flask и FastAPI. Jinja2 позволяет встраивать динамические данные в HTML-шаблоны. Например:

<h1>Информация про факультет {{ facultet_info["name"] }}</h1>

<h1>Информация про факультет {{ facultet_info.name }}</h1>

Если передать в шаблон переменную name = {"name": "Информатика"}, Jinja2 преобразует шаблон в следующий HTML-код:

<h1>Информация про факультет Информатика</h1>

В этой статье мы подробно рассмотрим, как использовать Jinja2 для создания динамических страниц и интеграции их с вашим FastAPI приложением.

Связь фронтенд и бэкенд фреймворков

На начальных этапах разработки многие новички задаются вопросом: «Можно ли связать фронтенд-фреймворк (например React), с бэкенд-фреймворком (например FastAPI)?» Ответ на этот вопрос — однозначное «да».

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

Таким образом, фронтенд и бэкенд работают как независимые, но взаимосвязанные компоненты, позволяя создавать гибкие и мощные веб-приложения.

Нужен ли бэкендеру фронтенд?

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

Основные аспекты понимания фронтенда для бэкендера

  1. Основы HTML и CSS: Бэкендеру важно иметь общее представление о том, как устроен HTML и CSS. Нужно понимать, что такое теги, атрибуты, классы и ID, а также как структурируются элементы на странице с помощью div, span, form и других элементов. Знание основ HTML и CSS поможет вам лучше понять, как данные передаются и отображаются на стороне клиента.

  2. Взаимодействие с API: Одной из ключевых задач фронтенда является взаимодействие с API, которое осуществляется через HTTP‑запросы. Бэкендер должен понимать, как фронтенд отправляет запросы к API, какие методы используются (GET, POST, PUT, DELETE) и как структурированы данные в запросах и ответах. Знание этих деталей поможет вам лучше спроектировать API и обеспечить его корректную работу.

  3. Форматы данных: Принципы работы с форматами данных, такими как JSON, являются важным аспектом взаимодействия между фронтендом и бэкендом. Понимание структуры данных, которые фронтенд ожидает получить от API, поможет вам создавать более точные и удобные API.

  4. Обработка ошибок и ответы: Бэкендер должен быть осведомлен о том, как фронтенд обрабатывает ошибки и какие ответы ожидает получать. Это включает в себя как успешные ответы, так и сообщения об ошибках. Создание понятных и информативных сообщений об ошибках поможет фронтенду корректно реагировать на проблемы и предоставлять пользователю адекватную информацию.

  5. Реактивность и асинхронность: Современные фронтенд‑фреймворки, такие как React, Angular или Vue, часто используют асинхронные запросы для обновления данных на странице без её перезагрузки. Понимание этих принципов поможет вам проектировать API, которые эффективно поддерживают асинхронное взаимодействие.

Почему это важно?

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

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

Желательно, чтоб перед тем как вы начнете читать далее, вы ознакомились с прошлыми моими статьями по теме разработки API через FastApi и ознакомились с базой фронтенда: что такое HTML и CSS. Сильно вникать не обязательно, но вам просто будет проще понимать последующий материал.

Что такое статические файлы и шаблоны

Тут мы уже подходим к FastApi. Так как суть последних публикаций в полном разборе FastApi, дальше мы не будем рассматривать подробно тему JS, так как это вообще отдельный язык программирования и, тем более, отдельные фреймворки на нем, но динамики на своих страничках мы сегодня достигнем.

Для того чтоб достичь этой динамики, как бы это ни было парадоксально, нам потребуются статические файлы.

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

  • Изображения (например,.jpg,.png)

  • CSS файлы (стили для оформления страниц)

  • JavaScript файлы (скрипты для интерактивности)

Эти файлы подключаются к HTML-страницам, и их содержимое остаётся неизменным до тех пор, пока вы сами не решите обновить их.

Шаблоны

Шаблоны — это HTML-файлы, которые содержат динамические элементы и могут изменяться в зависимости от переданных данных. Они представляют собой статические HTML-страницы, которые наполняются динамическим содержимым с помощью шаблонизаторов, таких как Jinja2. Это позволяет создавать страницы, которые могут адаптироваться к различным данным и условиям.

Простые примеры динамичной страницы

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

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

Я думаю, что теории достаточно и всем нам уже не терпится приступить к практике.

Приступим!

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

Так-же, давайте создадим папку templates, в которой мы будем хранить наши динамичные HTML шаблоны. Папку создаем так-же, в корне app.

Для начала нам нужно будет дополнить структуру нашего проекта. Нам необходимо будет создать 3 папки в корне проекта (app):

  • папку pages – внутри мы будем хранить файлы FastApi. Отвечающую за эндпоинты вокруг отрисовки страниц

  • папку templates – внутри будем хранить HTML, шаблоны, в которую вдохнем жизнь

  • паку static – внутри будем хранить статические файлы. Сразу создайте внутри папки images, style и js для хранения изображений, файлов стилей (css) и файлы JS.

Установим Jinja2, если этого еще не сделали:

pip install jinja2

Начнем заполнять папки

Создадим в папке pages обычный роутер, который будет отдавать странички (app/pages/router.py).

from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates


router = APIRouter(prefix='/pages', tags=['Фронтенд'])
templates = Jinja2Templates(directory='app/templates')


@router.get('/students')
async def get_students_html(request: Request):
    return templates.TemplateResponse(name='students.html', context={'request': request})
  

Тут основной смысл в том, что мы возвращаем не простой JSON (dict), а HTML страницу.

from fastapi.templating import Jinja2Templates

Тут мы импортировали шаблоны Jinja2Templates. Они нам, как раз, позволяют возвращать HTML страницы. Другими словами, тут мы даем полномочия по управлению нашими HTML страничками Jinja2. Это нам позволяет:

  • возвращать HTML страницы, а не просто JSON

  • передавать на HTML страницы наши данные

  • использовать питоновский синтаксис прямо на страницах HTML (об этом далее)

templates = Jinja2Templates(directory='app/templates')

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

@router.get('/students')
async def get_students_html(request: Request):
    return templates.TemplateResponse(name='students.html', context={'request': request})

Тут мы создали базовый эндпоинт, который будет возвращать для нас HTML шаблон. Как вы поняли по названию, возвращать она должна будет информацию по всем студентам.

Когда мы создаём эндпоинты в FastAPI, которые возвращают HTML-шаблоны с помощью Jinja2, важно учитывать несколько ключевых аспектов. Давайте подробно рассмотрим ваш пример и разберём, почему важно принимать объект Request в таких эндпоинтах

  • Шаблонизатор Jinja2 требует request: Jinja2 использует объект request для управления различными функциями, такими как URL‑генерация и работа с сессиями. Без передачи request в контекст шаблона, такие функции могут не работать корректно.

  • Интеграция с URL: Когда вы используете функции Jinja2 для генерации URL‑адресов в вашем шаблоне, как, например, с помощью url_for(), вам нужен доступ к объекту request, чтобы корректно формировать адреса. Например, если у вас есть ссылки на другие страницы или ресурсы, request помогает в их построении.

  • Обработка сессий и Cookies: Если вы используете сессии или cookies, которые могут влиять на отображаемый контент, вам нужно передавать request в шаблон, чтобы иметь доступ к этим данным.

Возврат HTML-шаблона:

В вашем примере вы возвращаете HTML-шаблон с помощью метода TemplateResponse:

return templates.TemplateResponse(name='students.html', context={'request': request})

  • name='students.html': Это имя файла шаблона, который вы хотите отрендерить. Убедитесь, что этот файл находится в директории, указанной при настройке Jinja2 в вашем приложении.

  • context={'request': request}: Здесь вы передаете контекст в шаблон (request — обязательный параметр!). В данном случае, вы передаете объект request, что позволяет шаблону использовать его для различных целей. Далее, в контексте, мы будем передавать нужные нам данные для отрисовки. Например это список студентов.

Теперь создадим нашу HTML страницу (templates/students.html)

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Все студенты</title>
</head>
<body>
<p>Тут будет информация по всем студентам. Пока просто смотрим.</p>
</body>
</html>

Обычно в IDE достаточно отправить ! И нажать на TAB, чтоб базовая разметка была создана. Далее я просто дал название странице - <title>Все студенты</title> и в теле прописал 1 абзац <p>Тут будет информация по всем студентам. Пока просто смотрим.</p>.

Подключим созданный роутер в main.py и проверим все ли у нас работает.

from app.pages.router import router as router_pages


app.include_router(router_pages)

Запустим приложени

uvicorn app.main:app —reload

Переходим по созданному эндпоинту

http://127.0.0.1:8000/pages/students

СКРИН СО СТРАНИЦАМИ СТУДЕНТОВ (1)

Далее, можно использовать комбинацию клавиш CTRL+U, чтоб посмотреть на код страницы.

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

Так-же, html код мы можем запросить и через документацию.

http://127.0.0.1:8000/docs

Теперь давайте «оживим» наш шаблон. Я напоминаю, что мы с вами писали такой роутер:

@router.get("/", summary="Получить всех студентов")
async def get_all_students(request_body: RBStudent = Depends()) -> list[SStudent]:
    return await StudentDAO.find_students(**request_body.to_dict())
  

Метод для получения информации по всем студентам из двух связанных таблиц выглядит так:

@classmethod
async def find_students(cls, **student_data):
    async with async_session_maker() as session:
        # Создайте запрос с фильтрацией по параметрам student_data
        query = select(cls.model).options(joinedload(cls.model.major)).filter_by(**student_data)
        result = await session.execute(query)
        students_info = result.scalars().all()

        # Преобразуйте данные студентов в словари с информацией о специальности
        students_data = []
        for student in students_info:
            student_dict = student.to_dict()
            student_dict['major'] = student.major.major_name if student.major else None
            students_data.append(student_dict)

        return students_data
            

Тут не использовались никакие зависимости и проверки. Смысл был в том, чтоб вернуть или всех студентов или студентов по конкретным фильтрам. Давайте установим зависимость нашего эндпоинта, который возвращает HTML страницу с эндпоинтом, который возвращает всех студентов.

Для этого, как вы догадались, мы используем Depends.

from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates

from app.students.router import get_all_students

router = APIRouter(prefix='/pages', tags=['Фронтенд'])
templates = Jinja2Templates(directory='app/templates')


@router.get('/students')
async def get_students_html(request: Request, users=Depends(get_all_students)):
    return templates.TemplateResponse(name='students.html', context={'request': request})

Посмотрим в документацию что у нас получилось.

Мы видим что теперь данный метод принимает параметры. Попробуем вбить параметры и выполним запрос.

Мы видим:

  1. Запрос выполнился корректной

  2. Никаких ошибок мы не получили

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

Исправим это, а именно, подключим данные о наших студентах в наш шаблон. Напоминаю, что у нас по каждому студенту есть следующая информация:

{
  "phone_number": "string",
  "first_name": "string",
  "last_name": "string",
  "date_of_birth": "2024-07-23",
  "email": "user@example.com",
  "address": "stringstri",
  "enrollment_year": 2002,
  "major_id": 1,
  "major": "stringstri",
  "course": 1,
  "special_notes": "string"
}

Скрин таблицы

Теперь передадим данные по студентам в наш шаблон.

@router.get('/students')
async def get_students_html(request: Request, students=Depends(get_all_students)):
    return templates.TemplateResponse(name='students.html',
                                      context={'request': request, 'students': students})
    

Напоминаю, что students — это список словарей, в котором находится информация по каждому студенту. Следовательно, в шаблоне нам нужно будет работать не просто со значениями, а именно со списком.

В других ситуациях таким списком может быть — список товаров, список массивов данных о погоде, о криптовалютах и прочее. То есть, бэкенд возвращает список неких массивов с данными, а фронт-энд это дело отрисовывает и выводит пользователю.

Добавим данные в наш HTML шаблон

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Все студенты</title>
</head>
<body>
    <h1>Список студентов</h1>
    <div>
        {% for student in students %}
        <div>
            <h2>ID: {{ student.id }}</h2>
            <p>Полное имя: {{ student.first_name }} {{ student.last_name }}</p>
            <p>Email: {{ student.email }}</p>
            <!-- Добавьте другие атрибуты по вашему усмотрению -->
        </div>
        {% endfor %}
    </div>
</body>
</html>

Тут вы можете увидеть немного видоизмененный базовый синтаксис Python, в частности цикл FOR.

context={'request': request, 'students': students}

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

В Jinja2 для работы с циклом FOR используется такая конструкция:

{% for item in items %}

{% endfor %}

Вот короткое описание некоторых конструкций Python в исполнении Jinja2:

Вывод переменной: {{ variable }}

<p>Привет, {{ name }}!</p>
<p>Привет, {{ user.name }}!</p>

Переменная подставляется всегда в двойные фигурные скобки. Если мы работаем со словарем (dict), то данные можно доставать или через точку (как в примере выше) или в формате стандартного питоновского синтаксиса Привет, {{ user['name'] }}, кроме того, можно вытягивать значения и по индексу.

<p>Хобби: {{ user['hobby'][1] }}!</p>

Условные конструкции (if/elif/else):

{% if condition %}
  <p>Condition is true!</p>
{% elif another_condition %}
  <p>Another condition is true!</p>
{% else %}
  <p>Condition is false!</p>
{% endif %}

Условная конструкция подставляется в блок

Цикл FOR (на примере отрисовки списка в HTML):

<ul>
  {% for item in items %}
    <li>{{ item }}</li>
  {% endfor %}
</ul>

Обратите внимание. Блоки всегда нужно закрывать!Фильтры:

Применение фильтров Jinja2 к переменным:

<p>{{ name|upper }}</p>
<p>{{ date|date("d-m-Y") }}</p>

{{ name|upper }}: Применяет фильтр upper к переменной name, что преобразует строку в верхний регистр. Например, если переменная name содержит "John", то в HTML будет отображено "JOHN".

{{ date|date("d-m-Y") }}: Применяет фильтр date к переменной date для форматирования даты. Формат "d-m-Y" указывает, что дата должна быть отображена в формате "день-месяц-год". Например, если переменная date содержит дату "2024-07-23", то в HTML будет отображено "23-07-2024".

В данной статье я не буду особо вдаваться в подробности Jinja2. А вы, если захотите подробную статью про работу с Jinja2 – дайте мне об этом знать через комментарии, лайки и голосование по данному поводу, которое вы найдете под этой статьей. Тут дело в том, что Jinja2 универсальна под любой Python бэкенд фреймворк.

Посмотрим как теперь выглядят наши данные.

А теперь взглянем на HTML код

На данный момент все выглядит достаточно грустно, но, при этом, мы уже сейчас видим что данные отрисованы динамически.

Теперь, давайте немного отвлечемся от HTML и подключим к нашему проекту статические файлы.

Для начала давайте дополним нашу таблицу со студентами одним значением — photo. В него мы будем подставлять название фотографии со студентом.

В модель students добавим:

photo: Mapped[str] = mapped_column(Text, nullable=True)

Далее выполним миграцию

alembic revision --autogenerate -m "add column photo"

И выполним обновление

alembic upgrade head

Скрин таблицы с новой колонкой:

Теперь изменим Pydantic модель студента, добавив описание поля photo

photo: Optional[str] = Field(None, max_length=100, description="Фото студента")

Загрузка файлов в FastApi через POST запросы

Теперь мы готовы к теме статических файлов. Сейчас я покажу вам на конкретном примере каким образом можно загружать в FastApi файлы и, как вы уже поняли, посмотрим мы на примере загрузки фотографий.

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

Импортируем объект UploadFile из FastApi

from fastapi import APIRouter, Request, Depends, UploadFile

Кроме того, импортируем shutil

import shutil

Эта библиотека встроена в Python и поможет она нам перехватить из оперативной памяти объект фото с последующим его сохранением. Тут ключевое "оперативная память". Следовательно, метод подходит только для работы с небольшими по размеру файлами, для больших файлов используются другие подходы, которые сегодня я описывать не буду.

Напишем эндпоинт для обрабоки POST запроса с фото:

@router.post('/add_photo')
async def add_student_photo(file: UploadFile, image_name: int):
    with open(f"app/static/images/{image_name}.webp", "wb+") as photo_obj:
        shutil.copyfileobj(file.file, photo_obj)

Принимать наш эндпоинт будет фото студента (file: UploadFile) и название фото. Так как фото будет всего 1 на каждого студента, я решил реализовать следующим образом:

  • передаем айди студента

  • сохраняем фото с именем {image_name}.webp

Формат webp нам нужен чтоб сжать фото без потери качества.

Обязательно создайте папку app/static/images, иначе вы получите ошибку.

Появился новый метод. Указываем ID фото и кликаем на "Выберите файл"
Появился новый метод. Указываем ID фото и кликаем на "Выберите файл"
Выбираем фото с компьютера и выполняем запрос
Выбираем фото с компьютера и выполняем запрос
Видим, что файл появился в нужном месте.
Видим, что файл появился в нужном месте.

Отлично! Фото сохраняется и теперь мы переходим непосредственно к статическим файлам. Нам необходимо каким-то образом указать, что FastApi должен подгружать именно файл и тут не все так очевидно как кажется.

Статические файлы

Использовать мы будем класс StaticFiles, который мы импортируем в main.py

from fastapi.staticfiles import StaticFiles

Теперь нам необходимо примонтировать к нашему приложению статические файлы. Сейчас они будут представлены только фото, но, чуть дальше, мы будем работать и со стилями.

app.mount('/static', StaticFiles(directory='app/static'), 'static')

Эта строка настраивает приложение FastAPI для обслуживания статических файлов. Давайте разберем, что здесь происходит:

  • app.mount: Метод mount позволяет «вмонтировать» под‑приложение в приложение FastAPI. В этом случае под‑приложение, которое монтируется, это объект StaticFiles.

  • '/static': Это путь, по которому будут доступны статические файлы. В данном случае, все запросы к пути, начинающемуся с /static, будут обслуживаться этим под‑приложением. Например, если у вас есть файл image.png в директории app/static, он будет доступен по URL http://yourdomain/static/image.png.

  • StaticFiles(directory='app/static'): StaticFiles — это класс из модуля fastapi.staticfiles, который используется для работы со статическими файлами. Параметр directory='app/static' указывает путь к директории, где хранятся статические файлы. Эти файлы будут доступны через указанный выше путь (/static).

  • 'static': Это имя маршрута, которое можно использовать для идентификации и ссылок на этот маршрут. В этом случае, оно не обязательно для функционирования, но может быть полезно для внутреннего управления и ссылок.

Надеюсь, что понятно объяснил.

Если резюмировать, то данным кодом мы создали некое подобие роутера, которое позволит, обращаясь по пути /static генерировать пути к статическим файлом.

К примеру, теперь фото будет доступно по адресу: http://127.0.0.1:8000/static/images/2.webp

Теперь, после предварительной подготовки, нам ничего не мешает не только подключить фото к нашему приложению, но и другие статические файлы, такие как фото.

Так как у нас тут не курс по HTML + CSS. Я сейчас выполню написание стилей и изменение HTML шаблона «за кулисами». Напоминаю, что посмотреть исходник полный исходник кода можно в моем телеграмм канале «Легкий путь в Python», а задать вопросы или обсудить интересующие вас темы, можно в сообществе «Легкий путь в Python - сообщество».

В этом коде я пропишу простые стили, которые позволят отобразить информацию по студентам в виде карточек, где каждая карточка студента будет занимать всю ширину страницы. Фото я сделаю круглыми. Так-же, добавлю кнопку, которая позволит открыть профиль стедента.

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

Если у вас есть базовые знания в HTML + CSS, можете выполнить верстку самостоятельно. А у меня вот что получилось.

Файл стилей (static/style/styles.css)

.student-card {
    border: 1px solid #ddd;
    padding: 10px;
    margin-bottom: 20px;
    border-radius: 5px;
    display: flex;
    align-items: center;
}


.student-card img {
    width: 100px;
    height: 100px;
    object-fit: cover;
    border-radius: 50%;
    margin-right: 15px;
}


.student-card .details {
    flex-grow: 1;
}


.student-card a {
    display: inline-block;
    padding: 10px 20px;
    border: none;
    background-color: #007bff;
    color: white;
    border-radius: 5px;
    text-decoration: none;
}


.student-card a:hover {
    background-color: #0056b3;
}

Эти стили формируют карточку студента с круглым изображением слева и текстовой частью справа. Текстовая часть расширяется, чтобы занять доступное пространство, а справа от карточки находится кнопка (ссылка), которая выделяется ярким синим цветом и меняет цвет при наведении курсора.

Теперь изменим наш HTML шаблон

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Все студенты</title>
    <link rel="stylesheet" type="text/css" href="/static/style/styles.css">
</head>
<body>
    <h1>Список студентов</h1>
    <div>
        {% for student in students %}
        <div class="student-card">
            <img src="/static/images/{{ student.id }}.webp" alt="Фото студента">
            <div class="details">
                <h2>ID: {{ student.id }}</h2>
                <p>Полное имя: {{ student.first_name }} {{ student.last_name }}</p>
                <p>Email: {{ student.email }}</p>
                <p>Специальность: {{ student.major }}</p>
                <p>Курс: {{ student.course }}</p>
                <!-- Добавьте другие атрибуты по вашему усмотрению -->
            </div>
            <a href="/students/{{ student.id }}">Смотреть всю информацию</a>
        </div>
        {% endfor %}
    </div>
</body>
</html>

Тут сразу стоит обратить внимание на импорты статических файлов.

<link rel="stylesheet" type="text/css" href="/static/style/styles.css">

Данная строка прописывается в блоке head и, благодаря ней, мы импортируем наши стили из папки static/style/styles.css.

Далее идет уже знакомый вам синтаксис, но с подвязкой наших созданных классов для стилей.

Так как мы изначально не закладывали фото в базу данных я решил немного схитрить и подгрузить фото студента следующим образом:

<img src="/static/images/{{ student.id }}.webp" alt="Фото студента">

Напоминаю, что фото у нас имеют название {{ student.id }}.webp, что позволяет нам подставить ID студента и получить нужное нам фото.

<a href="/pages/students/{{ student.id }}">Смотреть всю информацию</a>

В этом месте мы сформировали ссылку, которая будет вести на страницу студента. Метод для вывода всей информации по конкретному студенту мы уже описывали и я чуть позже напомню код.

Проверим что у нас получилось (http://127.0.0.1:8000/pages/students).­

В целом неплохо для бэкенд разработчика?
В целом неплохо для бэкенд разработчика?

Видим, что все прошло ровно так как мы планировали, а это значит что можно приступить к странице студента.

Напоминаю, что метод для получения всей информации по студенту выглядит так.

@router.get("/{student_id}", summary="Получить одного студента по id")
async def get_student_by_id(student_id: int) -> SStudent | dict:
    rez = await StudentDAO.find_full_data(student_id=student_id)
    if rez is None:
        return {'message': f'Студент с ID {student_id} не найден!'}
    return rez

Получение полной информации о студенте с двух таблиц:

@classmethod
async def find_full_data(cls, student_id):
    async with async_session_maker() as session:
        # Query to get student info along with major info
        query = select(cls.model).options(joinedload(cls.model.major)).filter_by(id=student_id)
        result = await session.execute(query)
        student_info = result.scalar_one_or_none()

        # If student is not found, return None
        if not student_info:
            return None

        student_data = student_info.to_dict()
        student_data['major'] = student_info.major.major_name
        return student_data

То есть, если бы мы просто использовали старый метод, то по переходу на страницу http://127.0.0.1:8000/students/4, получали бы такой результат:

{
  "id": 4,
  "phone_number": "+4543555133",
  "first_name": "Никита",
  "last_name": "Петров",
  "date_of_birth": "2000-07-23",
  "email": "use4r@example.com",
  "address": "stringstri stringstri",
  "enrollment_year": 2002,
  "major_id": 2,
  "course": 2,
  "special_notes": "string",
  "photo": null,
  "major": "Философия"
}

В целом работает, но для обычных пользователей лучше оформим по другому. Теперь, по аналогии с прошлым шагом, я опишу HTML-шаблон и css стили для карточки студента.

Опишем стили в файл student.css

/* Файл: static/style/student.css */

body {
    font-family: Arial, sans-serif;
    margin: 0;
    padding: 20px;
    background-color: #f5f5f5;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    padding: 20px;
    background-color: #fff;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

h1 {
    font-size: 24px;
    color: #333;
    margin-bottom: 20px;
}

.student-profile {
    display: flex;
    align-items: center;
    border-bottom: 1px solid #ddd;
    padding-bottom: 20px;
    margin-bottom: 20px;
}

.student-profile img {
    width: 150px;
    height: 150px;
    object-fit: cover;
    border-radius: 50%;
    margin-right: 20px;
}

.student-info {
    flex-grow: 1;
}

.student-info h2 {
    margin: 0;
    font-size: 22px;
    color: #333;
}

.student-info p {
    margin: 5px 0;
    font-size: 16px;
    color: #555;
} 

.back-link {
    display: inline-block;
    padding: 10px 20px;
    border: none;
    background-color: #007bff;
    color: white;
    border-radius: 5px;
    text-decoration: none;
    margin-top: 20px;
}

.back-link:hover {
    background-color: #0056b3;
}
  • body: Определяет основной шрифт, отступы и фон страницы.

  • .container: Создает центральный блок для размещения контента с максимальной шириной, отступами, фоном и тенью.

  • .student-profile: Стили для контейнера с информацией о студенте, включая изображение и текст.

  • .student-profile img: Оформляет изображение студента, делая его круглым и задавая размеры.

  • .student-info: Определяет стили для блока с текстовой информацией о студенте.

  • .back-link: Стили для кнопки возврата к списку студентов, включая цвет фона, текст и эффект при наведении.

Создадим HTML шаблон

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Информация о студенте</title>
    <link rel="stylesheet" type="text/css" href="/static/style/student.css">
</head>
<body>
    <div class="container">
        <h1>Информация о студенте</h1>
        <div class="student-profile">
            <img src="/static/images/{{ student.id }}.webp" alt="Фото студента">
            <div class="student-info">
                <h2>ID: {{ student.id }}</h2>
                <p><strong>Полное имя:</strong> {{ student.first_name }} {{ student.last_name }}</p>
                <p><strong>Email:</strong> {{ student.email }}</p>
                <p><strong>Дата рождения:</strong> {{ student.date_of_birth }}</p>
                <p><strong>Адрес:</strong> {{ student.address }}</p>
                <p><strong>Год поступления:</strong> {{ student.enrollment_year }}</p>
                <p><strong>Курс:</strong> {{ student.course }}</p>
                <p><strong>Специальность:</strong> {{ student.major }}</p>
                <p><strong>Специальные заметки:</strong> {{ student.special_notes }}</p>
            </div>
        </div>
        <a href="/students" class="back-link">Вернуться к списку студентов</a>
    </div>
</body>
</html>

HTML-шаблон:

  • Включает основной контейнер для страницы, который отображает информацию о студенте.

  • Использует флекс‑контейнер для выравнивания изображения и текста.

  • Вставляет данные студента в соответствующие элементы.

  • Добавляет ссылку для возврата к списку студентов.

Теперь нам остается настроить эндпоинт, который будет возвращать этот HTML шаблон.

@router.get('/students/{student_id}')
async def get_students_html(request: Request, student=Depends(get_student_by_id)):
    return templates.TemplateResponse(name='student.html',
                                      context={'request': request, 'student': student})

В данном случае происходит настоящая магия. Ведь мы никак не указывали явно, что в get_student_by_id должен подставиться student_id, но это происходит.

В FastAPI, когда вы используете зависимость с помощью Depends(), она автоматически обрабатывает зависимость на основе параметров запроса и функции зависимости.

В результате, функция get_student_by_id корректно получает параметр student_id от FastAPI и возвращает данные, которые затем передаются в шаблон для рендеринга.

Этот механизм делает управление зависимостями и параметрами запросов очень удобным и автоматизированным в FastAPI.

Посмотрим на страницу карточки студента.

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

Теперь я продемонстрирую вам применение JS скриптов на страницах FastApi приложения. При их помощи мы будем собирать данные со страницы (с формы) и затем будем выполнять пост запросы.

Рассмотрим это на примерах: регистрации, аутинтификации и выхода со страницы.

Регистрация пользователя

На данном этапе нам необходимо будет подкинуть не только стили и HTML, но и JS скрипт. Я понимаю, что вы можете быть совсем незнакомы с JavaScript, но далее, как минимум, вы поймета как интегрировать новый язык программирования в страницы FastApi приложения. Я же, со своей стороны, постараюсь не усложнять и не перегружать вас информацией.

Эндпоинт для регистрации у нас имеет такой вид:

@router.post("/register/")
async def register_user(user_data: SUserRegister) -> dict:
    user = await UsersDAO.find_one_or_none(email=user_data.email)
    if user:
        raise UserAlreadyExistsException
    user_dict = user_data.dict()
    user_dict['password'] = get_password_hash(user_data.password)
    await UsersDAO.add(**user_dict)
    return {'message': f'Вы успешно зарегистрированы!'}

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

class SUserRegister(BaseModel):
    email: EmailStr = Field(..., description="Электронная почта")
    password: str = Field(..., min_length=5, max_length=50, description="Пароль, от 5 до 50 знаков")
    phone_number: str = Field(..., description="Номер телефона в международном формате, начинающийся с '+'")
    first_name: str = Field(..., min_length=3, max_length=50, description="Имя, от 3 до 50 символов")
    last_name: str = Field(..., min_length=3, max_length=50, description="Фамилия, от 3 до 50 символов")

    @field_validator("phone_number")
    @classmethod
    def validate_phone_number(cls, values: str) -> str:
        if not re.match(r'^\+\d{5,15}$', values):
            raise ValueError('Номер телефона должен начинаться с "+" и содержать от 5 до 15 цифр')
        return values
      

Напоминаю, что с полным исходником кода проекта можно ознакомиться в моем телеграмм канале «Легкий путь в Python».

Теперь приступим к фронту. Опишем стили:

/* Общие стили для страницы */
body {
    font-family: Arial, sans-serif;
    background-color: #f4f4f4;
    margin: 0;
    padding: 0;
}

/* Контейнер для формы */
.container {
    max-width: 500px;
    margin: 50px auto;
    padding: 20px;
    background-color: #fff;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

/* Заголовок страницы */
h1 {
    margin-top: 0;
    color: #333;
}

/* Стили формы */
.registration-form {
    display: flex;
    flex-direction: column;
}

/* Поля ввода */
.registration-form label {
    margin-bottom: 5px;
    font-weight: bold;
}

.registration-form input[type="email"],
.registration-form input[type="password"],
.registration-form input[type="tel"],
.registration-form input[type="text"] {
    padding: 10px;
    margin-bottom: 15px;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 16px;
}

/* Кнопка отправки */
.submit-button {
    padding: 10px 20px;
    border: none;
    background-color: #007bff;
    color: white;
    border-radius: 4px;
    font-size: 16px;
    cursor: pointer;
}

.submit-button:hover {
    background-color: #0056b3;
}

Достаточно простые стили, которые описывают внешний вид нашей формы регистрации. Эти же стили мы используем и на форме авторизации.

Теперь напишем JS-скрипт (js/script.js)

async function regFunction(event) {
    event.preventDefault();  // Предотвращаем стандартное действие формы

    // Получаем форму и собираем данные из неё
    const form = document.getElementById('registration-form');
    const formData = new FormData(form);
    const data = Object.fromEntries(formData.entries());

    try {
        const response = await fetch('/auth/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        });

        // Проверяем успешность ответа
        if (!response.ok) {
            // Получаем данные об ошибке
            const errorData = await response.json();
            displayErrors(errorData);  // Отображаем ошибки
            return;  // Прерываем выполнение функции
        }

        const result = await response.json();

        if (result.message) {  // Проверяем наличие сообщения о успешной регистрации
            window.location.href = '/pages/login';  // Перенаправляем пользователя на страницу логина
        } else {
            alert(result.message || 'Неизвестная ошибка');
        }
    } catch (error) {
        console.error('Ошибка:', error);
        alert('Произошла ошибка при регистрации. Пожалуйста, попробуйте снова.');
    }
}

// Функция для отображения ошибок
function displayErrors(errorData) {
    let message = 'Произошла ошибка';

    if (errorData && errorData.detail) {
        if (Array.isArray(errorData.detail)) {
            // Обработка массива ошибок
            message = errorData.detail.map(error => {
                if (error.type === 'string_too_short') {
                    return `Поле "${error.loc[1]}" должно содержать минимум ${error.ctx.min_length} символов.`;
                }
                return error.msg || 'Произошла ошибка';
            }).join('\n');
        } else {
            // Обработка одиночной ошибки
            message = errorData.detail || 'Произошла ошибка';
        }
    }

    // Отображение сообщения об ошибке
    alert(message);
}

Коротко объясню что тут вообще происходит.

В данном коде вы видите наличие двух функций: regFunction и displayErrors. Разберемся с каждой.

Функция regFunction

Основная задача данной функции — сбор данных с формы регистрации и выполнение при их помощи POST запроса.

Для выполнения асинхронного пост запроса мы используем встроенный в JS модуль — fetch.

const form = document.getElementById('registration-form');
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());

При помощи этих строк мы извлекаем данные из введенных пользователем в форму и трансформируем их в удобный для дальнейшей работы формат.

Далее мы выполняем стандартный асинхронный пост запрос, трансформируя объект данных в JSON (body: JSON.stringify(data)).

Далее идет простая проверка и в случае, если в ответе присутствует сообщение про успешную регистрацию — мы выполняем переадресацию на страницу с авторизацией.

Более подробное описание асинхронной работы с fetch, в контексте отправки POST запроса вы сможете найти в сети интернет. Данных предостаточно.

Функция displayErrorsОбработчик ошибок я описал отдельно

// Функция для отображения ошибок
function displayErrors(errorData) {
    let message = 'Произошла ошибка';

    if (errorData && errorData.detail) {
        if (Array.isArray(errorData.detail)) {
            // Обработка массива ошибок
            message = errorData.detail.map(error => {
                if (error.type === 'string_too_short') {
                    return `Поле "${error.loc[1]}" должно содержать минимум ${error.ctx.min_length} символов.`;
                }
                return error.msg || 'Произошла ошибка';
            }).join('\n');
        } else {
            // Обработка одиночной ошибки
            message = errorData.detail || 'Произошла ошибка';
        }
    }

    // Отображение сообщения об ошибке
    alert(message);
}

Смысл в том, чтоб перехватить «details» (детали ошибки) и отобразить их в окне Alert.

Друзья, я понимаю, что на старте это может показаться трудным и вы не обязаны на уровне профессионала это все понять. Но, что точно не помешает, так это понимание того как через JS можно связывать фронт и бэк. Кроме того, обработка POST запроса от JS это та задача, с которой вы будете сталкиваться чаще всего в процессе бэкенд разработки.

Пишем HTML

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Регистрация</title>
    <link rel="stylesheet" type="text/css" href="/static/style/register.css">
</head>
<body>
<div class="container">
    <h1>Регистрация</h1>
    <form id="registration-form" class="registration-form">
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required>

        <label for="password">Пароль:</label>
        <input type="password" id="password" name="password" required>

        <label for="phone_number">Телефон:</label>
        <input type="tel" id="phone_number" name="phone_number" required>

        <label for="first_name">Имя:</label>
        <input type="text" id="first_name" name="first_name" required>

        <label for="last_name">Фамилия:</label>
        <input type="text" id="last_name" name="last_name" required>
        <button type="submit" id="reg-button" class="submit-button" onclick="regFunction(event)">Зарегистрироваться</button>    </form>
</div>

<!-- Подключаем внешний JavaScript-файл -->
<script src="/static/js/script.js"></script>
</body>
</html>

Тут вы видите знакомые нам импорты стилей.

Далее есть стандартное описание формы регистрации и из того что заслуживает внимание в ней, так это строка:

<button type="submit" id="reg-button" class="submit-button" onclick="regFunction(event)">Зарегистрироваться</button>

В данной строке мы описали конструкцию onclick="regFunction()". Тем самым мы указали, что при клике на эту кнопку должна будет выполниться функция "regFunction()".

Существуют и другие способы привязки функции JS к клику по кнопке, но я выбрал этот именно из-за своей наглядности.

Проверяем

В целом, выглядит прилично
В целом, выглядит прилично

Заполню ее данными

После заполнения данных кликаем на кнопку «Зарегистрироваться»

Видим, что нас успешно перекинуло на новую страницу, но, так как мы не описали отдельно страничку для входа видим неприглядный результат. Но мы это ещё поправим.

Смотрим в базу данных и видим что запись успешно добавлена.

Теперь попробуем выполнить регистрацию с той-же почтой:

Получаем ошибку что пользователь уже существует, что, логично. Отлично. Теперь создадим страничку для аутинтификации пользователя.

Напоминаю, что после успешной аутинтификации пользователя в нашей системе генерируется JWT токен, который помещается в куку. Данный механизм я подробно описал в своей предыдущей статье.

@router.post("/login/")
async def auth_user(response: Response, user_data: SUserAuth):
    check = await authenticate_user(email=user_data.email, password=user_data.password)
    if check is None:
        raise IncorrectEmailOrPasswordException
    access_token = create_access_token({"sub": str(check.id)})
    response.set_cookie(key="users_access_token", value=access_token, httponly=True)
    return return {'ok': True, 'access_token': access_token, 'refresh_token': None, 'message': 'Авторизация успешна!'}

На выходе добавил два ключа: ok и message, чтоб привести вывод к одному виду.

На входе форма авторизации принимать должна два параметра: email и password.

Создадим HTML

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Аутинтификация</title>
    <link rel="stylesheet" type="text/css" href="/static/style/register.css">
</head>
<body>
<div class="container">
    <h1>Форма Входа</h1>
    <form id="login-form" class="registration-form">
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" required>
        <label for="password">Пароль:</label>
        <input type="password" id="password" name="password" required>
        <button type="submit" id="login-button" class="submit-button" onclick="loginFunction(event)">Войти</button>
    </form>
</div>

<!-- Подключаем внешний JavaScript-файл -->
<script src="/static/js/script.js"></script>
</body>
</html>

Тут особых отличий с первой формой нет. Единственное что мы изменили логин кнопки для отправки данных и привязали к клику loginFunction.

Теперь опишем функцию для аутентификации пользователя (файл js/script.js).

async function loginFunction(event) {
    event.preventDefault();  // Предотвращаем стандартное действие формы

    // Получаем форму и собираем данные из неё
    const form = document.getElementById('login-form');
    const formData = new FormData(form);
    const data = Object.fromEntries(formData.entries());

    try {
        const response = await fetch('/auth/login', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(data)
        });

        // Проверяем успешность ответа
        if (!response.ok) {
            // Получаем данные об ошибке
            const errorData = await response.json();
            displayErrors(errorData);  // Отображаем ошибки
            return;  // Прерываем выполнение функции
        }

        const result = await response.json();

        if (result.message) {  // Проверяем наличие сообщения о успешной регистрации
            window.location.href = '/pages/profile';  // Перенаправляем пользователя на страницу логина
        } else {
            alert(result.message || 'Неизвестная ошибка');
        }
    } catch (error) {
        console.error('Ошибка:', error);
        alert('Произошла ошибка при входе. Пожалуйста, попробуйте снова.');
    }
}

Код практически не отличается от своего аналога, ведь всю логику по генерации JWT и установке его в куки выполняет наш бэкенд.

window.location.href = '/pages/profile';

Тут вы видите, что переадресация тут описана в pages/profile — некое подобие личного профиля. Смысл в том, что для авторизованных пользователей будет открываться страница с его личными данными, а для неавторизованных сделаем пере адресацию на страцицу авторизации (немного позже).

Опишем эндпоинт:

@router.get('/login')
async def get_students_html(request: Request):
    return templates.TemplateResponse(name='login_form.html', context={'request': request})

Тут нет необходимости в зависимостях, так как мы их прописали явно в JS скрипте (отправка запроса).

Проверим

Проверим куки

Видим что куки установлены.

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

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

Немного под это дело изменим страничку со студентом и там добавим кнопку для выхода из системы (очистка куки).

Устанавливать зависимость мы будем с этим эндпоинтом:

@router.get("/me/")
async def get_me(user_data: User = Depends(get_current_user)):
    return user_data

CSS используем тот же что и для страницы студента (единственное только, дополнительно описал стили кнопки выхода).

.submit-button {
    padding: 10px 20px;
    border: none;
    background-color: #007bff;
    color: white;
    border-radius: 4px;
    font-size: 16px;
    cursor: pointer;
}

.submit-button:hover {
    background-color: #0056b3;
}

HTML будет выглядеть так:

<!doctype html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Страница профиля</title>
    <link rel="stylesheet" type="text/css" href="/static/style/student.css">
</head>
<body>
<div class="container">
    <h1>Мой профиль</h1>
    <div class="student-profile">
        <div class="student-info">
            <h2>ID: {{ profile.id }}</h2>
            <p><strong>Полное имя:</strong> {{ profile.first_name }} {{ profile.last_name }}</p>
            <p><strong>Email:</strong> {{ profile.email }}</p>
            <p><strong>Пароль в хэше:</strong> {{ profile.password }}</p>
        </div>
    </div>
    <button type="submit" id="logout-button" class="submit-button" onclick="logoutFunction()">Выйти</button>
</div>
<script src="/static/js/script.js"></script>
</body>
</html>

Ранее мы уже готовили эндпоинт для удаления access_token из куки:

@router.post("/logout/")
async def logout_user(response: Response):
    response.delete_cookie(key="users_access_token")
    return {'message': 'Пользователь успешно вышел из системы'}

Его мы и используем. Смысл в том, что при клике на кнопку «Выйти» будет выполнятся POST запрос для удаления ключа с токеном, а после будет происходить переадресация на страницу с формой входа.

Тут есть интересный момент. Управлять куками можно и через фронт (JS): добавлять, удалять, изменять, читать и прочее. Но, если эти куки устанавливал бжкенд, то изменение и удаление с фронта будет невозможно. Будьте внимательны.

JS-скрипт

async function logoutFunction() {
    try {
        // Отправка POST-запроса для удаления куки на сервере
        let response = await fetch('/auth/logout', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            }
        });

        // Проверка ответа сервера
        if (response.ok) {
            // Перенаправляем пользователя на страницу логина
            window.location.href = '/pages/login';
        } else {
            // Чтение возможного сообщения об ошибке от сервера
            const errorData = await response.json();
            console.error('Ошибка при выходе:', errorData.message || response.statusText);
        }
    } catch (error) {
        console.error('Ошибка сети', error);
    }
}

С логикой, в целом, вы знакомы.

Теперь напишем сам эндпоинт для загрузки странички профиля.

@router.get('/profile')
async def get_my_profile(request: Request, profile=Depends(get_me)):
    return templates.TemplateResponse(name='profile.html',
                                      context={'request': request, 'profile': profile})
    

Обратите внимание на зависимость. Не забудьте импортировать эндпоинт!

Проверяем

Я специально не закрыл панель разработчика, чтоб вы могли видеть наличие ключа. Кликнем на «Выйти» и посмотрим что у нас получится.

Ключ очищен. Теперь попробуем открыть страницу профиля (без авторизации).

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

Данная тема достаточно специфичная и я решил, что рассмотрю ее исключительно в своем телеграмм канале «Легкий путь в Python» исключительно для подписчиков канала.

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

Заключение

Как это часто бывает, на подготовку этой статьи ушло много времени, и в результате появилась такая большая и подробная работа о связке бэкэнда на FastAPI и фронтенда на чистом JS, CSS и HTML с использованием Jinja2.

Не стоит воспринимать эту статью как идеальный пример лучших практик, особенно в контексте фронтенд-разработки. Я сам не являюсь профессиональным фронтендером, и часть JS-кода, а также его интеграцию я сознательно упростил. Специалисты, возможно, заметят это в блоке в блоке интеграции функций JS в HTML-код.

Однако в этом и заключается основная идея. Я хотел показать, что для создания качественного фронтенда для вашего бэкэнда на FastAPI не обязательно быть экспертом в фреймворках фронтенда. Базовых знаний в CSS и HTML, немного понимания JS и умение работать с шаблонизаторами типа Jinja2 вполне достаточно для создания динамической визуализации вашего API.

Для небольших проектов, например, админки для телеграм-бота, часто достаточно использования Jinja2. Для более серьезных задач разработка обычно осуществляется в команде.

Если вы решите глубже погрузиться в фронтенд-разработку, я настоятельно рекомендую начать с Vue 3. Лично мне после чистого Python было приятно и безболезненно изучать и осваивать этот замечательный фреймворк. Если будет интерес, возможно, в будущем я подробно рассмотрю его в будующих публикациях

Что касается FastAPI, в дальнейшем, планирую раскрыть следующие темы:

  • Фоновые задачи с Celery и Flower

  • Кэширование с Redis

  • Создание админки

  • Pytest для тестирования API

  • Деплой

Эти темы будут представлены в зависимости от вашей поддержки, которую вы можете выразить лайками, комментариями под этой или другими публикациями и подпиской на мой телеграм-канал «Легкий путь в Python». Ваша поддержка будет для меня отличной мотивацией продолжать делиться знаниями и описывать различные технологии.

До скорого!

Комментарии (0)