Привет Хабр! Меня зовут Вячеслав Разводов, я ведущий разработчик Группы "Иннотех".

Мир покера – увлекательный и непредсказуемый. Волнение перед каждой раздачей, стратегические решения, анализ оппонентов – все это создавало уникальную атмосферу напряжения и интриги. Моя страсть к покеру не знала границ, и я уделял этому искусству не только массу времени, но и старался постоянно совершенствоваться. Читал книги, учился считать ауты. И конечно, много играл с друзьями и онлайн-площадках PokerStarts, PokerDom. Время шло, моя страсть к покеру под остыла. Однажды я получил предложение поучаствовать в проекте связанным с покерной тематикой. Конечно я согласился не раздумывая.

В первой версии проект состоял из 3 частей:

  • телеграмм бот, с тестами по игровым ситуация для техасского холдема;

  • админка, где заказчик набивал вопросы и варианты ответов;

  • api, по которому для телеграмм бот мог забирать данные о тестах, пользователях и так далее.

Я был ответственен за разработку административной панели и API. Тогда я активно изучал фреймворк Django. Django имеет встроенную административную панель (Django Admin), которая допускает ее гибкую настройку. С помощью дополнения Django REST framework (DRF), есть возможность создания REST API с минимальным затратами времени. Поэтому для реализации проекта выбрал Django.

Мы смогли быстро реализовать первую версию проекта, что вызвало большую радость у заказчика. В связи с этим, он просил нас усовершенствовать проект, добавив в него таблицу Hand Chart. Задача состояла в создании инструмента работы Hand Chart в Django Admin.

Используя данные добавленные в административной панели, нужно было выводить эту таблицу и для обычных пользователей. Создания инструмента для работы таблицей Hand Chart в Django Admin стало для меня вызовом.

Что представляла из себя таблица Hand Chart?

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

Количество доступных комбинаций пар карт для успешной игры - влияет несколько факторов:

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

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

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

Стек является важным для стратегии игры и может влиять на принятие решений. В общем, чем больше фишек (или стек) у игрока, тем более гибкими могут быть его действия: он может больше рисковать, пытаться интимидировать оппонентов большими ставками и так далее. Если же стек игрока мал, то ему придется быть более консервативным и осторожным в своих действиях, чтобы не потерять все фишки слишком быстро. Соотвественно с большим стеком, можете позволить играть более широкий диапазон рук, в то время как с маленьким стеком диапозон рук значительно сокращается.

Позиция игрока за столом, является не менее важным аспектом стратегии игры в покер. Она может разделяться на три основные категории: ранняя, средняя и поздняя позиции:

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

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

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

Рис. 1. Позиции игрока за покерным столом.
Рис. 1. Позиции игрока за покерным столом.

Позиция меняется по кругу, после каждой раздачи карт.

Исходя из этого, Hand Chart (рис. 2) предоставляет игроку удобный и наглядный способ оценить силу своих стартовых карт и принять решение о дальнейшей стратегии игры. По диагонали расположены “пары” карт, одинакового достоинства. Выше диагонали, располагают комбинации стартовых рук, разных мастей, ниже когда комбинации карт одной масти. Такой подход позволяет быстро ориентироваться, среди 169 комбинаций и принимать решения о стратегии игры.

Рис. 2. Таблица Hand Chart с возможными комбинациями стартовых карт для Техасского холдем.
Рис. 2. Таблица Hand Chart с возможными комбинациями стартовых карт для Техасского холдем.

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

Заказчик ставил целью проекта сделать таблицы Hand Chart под разные стеки - информационным продуктом. Чтобы повысить информативность и сделать таблицу более привлекательной визуально, заказчик предложил заменить обычные квадратики (из примера рис. 2) на "фишки" (Рис. 3).

Рис. 3. Пример "фишки" (ячейки) таблицы Hand Chart
Рис. 3. Пример "фишки" (ячейки) таблицы Hand Chart

Структура "фишки" разделена на восемь секторов. На внешнем сером крае каждого сектора представлены сокращения, отражающие позиции за столом. Средняя часть сектора, которая на рис. 3 окрашена в зеленый цвет, заполняется разными цветами. Каждый цвет соответствует действию игрока. В одном секторе не выводится больше 2 вариантов. В центре расположено обозначение комбинации карт. В зависимости от цветового сочетания возможны следующие варианты:

  • Черные буквы на сером фоне обозначают пары карт одного достоинства.

  • Черные буквы на белом фоне представляют собой пары карт разных мастей.

  • Белые буквы на черном фоне обозначают пары карт одной масти.

Если взять таблицу с рис. 2. заменим, квадраты на описаный вариант “фишки” получается следующий результат рис. 4.

Рис. 4. Вариант таблицы Hand Chart
Рис. 4. Вариант таблицы Hand Chart

Такая методика превращает таблицу Hand Chart в удобный и наглядный инструмент. Игрок получает советы, основанные на позиции за столом и комбинации карт, которые у него на руках. Использование разных цветов облегчает восприятие и понимание информации. Такая таблица составляется с учетом конкретного стека. Такой Hand Chart объединяет стек, позицию за столом и комбинации карт в руке, что приводит к более точным рекомендациям. В результате игроки получают лучшее понимание текущего положения и стратегии, что увеличивает шансы на победу.

Постановка задачи

Django Admin - мощный инструмент, который обеспечивает администраторам полный контроль над управлением и изменением данных в базе. Стандартный функционал Django Admin оказывается недостаточным, для отображения таблицы Hand Chart. Традиционное представление записей в таблице админ-панели не будет удобным и понятным для пользователя.

Задачу создания инструмента для Hand Chart можно разбить на части:

  • реализовать CRUD в административной панели для стеков игры (GameStacks) и модель для хранения даных

  • реализовать CRUD в административной панели для вариантов действий (OptionsAction) и модель для хранения даных

  • реализовать справочник “фишек” в административной панели, подразумевается все комбинации карт для таблицы Hand Chart (PokerChips) и модель для хранения даных

  • добавить свою страницу в Django Admin, чтобы она была доступа в меню как список стеков игры, список действий и т.д. НО вместо обычно таблицы, вывод соответствовал рис. 3. По клику на “фишку” - открывалась форма редактирования записи.

Разработка решения

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

Для создания приложения, в консоли находясь в каталоге проекта выполните команду:

# Команда для создания шаблона приложения
> python3 manage.py startapp hand_chart

После выполнения командны, в проекте появится новая папка с название hand_chart.

Создание моделей

Определим структуру базы данных нашего приложения, для этого внесем изменения в файл models.py. Этот файл содержит классы, наследующие класс Model. Каждый класс описывает структуру таблицы в базе данных.

Рассмотрим класс GameStacks, который описывает хранение данных о стеках игры в БД.

from django.db import models


class GameStacks(models.Model):
    """Справочник стеков игры."""

    class Meta:
        verbose_name = 'Стек игры'
        verbose_name_plural = '1. Стеки игры'
        ordering = ('sort', )

    name = models.CharField("Название", max_length=200)
    sort = models.PositiveIntegerField("Сортировка", default=500)

    def __str__(self):
        return self.name

Таблица GameStacks будет иметь два поля:

  • name, название стека, максимальная длина строки 200

  • sort, целочисленное значение по которому мы будет сортировать записи при выводе, по умолчанию равно 500.

class Meta в модели Django используется для настройки метаданных модели. Метаданные - это всего лишь "данные о данных", они не являются полем модели. Некоторые из настроек, которые вы можете добавить в класс Meta, включают verbose_name, verbose_name_plural, и ordering.

  • verbose_name - это человеко-понятное имя одного объекта модели. Если это не указано, Django автоматически создаст его, используя имя класса модели.

  • verbose_name_plural - это человеко-понятное имя для нескольких объектов модели. Если это не указано, Django автоматически добавит 's' в конец verbose_name.

  • ordering - это параметр, указывающий порядок, в котором должны быть возвращены объекты модели. Это может быть полем или несколькими полями. Здесь указана сортировка по полю sort ASC - то есть от меньшего к большему. Если написать ordering = ('-sort', ) - то сортировка будет от большего к меньшему (DESC)

В общем, класс Meta в модели Django позволяет настроить метаданные модели для удобства использования и управления.

Метод __str__ в Python является "магическим" методом, который возвращает строковое представление объекта. В контексте Django моделей, переопределение метода __str__ обычно используется для того, чтобы когда вы печатаете или отображаете экземпляр модели, возвращается информативное строковое представление.

Для создания модели справочника OptionsAction нам потребуется использовать библиотеку colorfield. С её помощью мы определим поле color. Хотя это поле сохраняет цвет в формате шестнадцатеричного кода в базе данных, оно предоставляет удобный инструмент для выбора цвета в административной панели (рис. 5).

Рис. 5. Поле color с возможность выбора цвета на диаграмме.
Рис. 5. Поле color с возможность выбора цвета на диаграмме.

В модели OptionsAction определим следующие поля:

  • name, краткое обозначение действия;

  • color, цвет которым будем закрашивать сектор;

  • description, не обязательно поле описания действия - по умолчанию, будет пустым.

from colorfield.fields import ColorField


class OptionsAction(models.Model):
    """Справочник вариантов действий."""

    class Meta:
        verbose_name = 'Вариант действия'
        verbose_name_plural = '2. Варианты действий'

    name = models.CharField("Название", max_length=200)
    color = ColorField("Цвет", default="#FFFFFF")
    description = models.TextField("Описание", max_length=200, default='', blank=True)

    def __str__(self):
        return f'{self.name} ({self.description})'

Назовем справочник комбинаций карт PokerChips, поскольку они будут представлены в форме покерных фишек. В этой модели, поле suit не является просто текстовым полем - оно содержит один из вариантов из списка SUIT_CHOICES. Первый элемент кортежа представляет собой кодовое обозначение, второй - это человекочитаемый текст, который будет отображаться в выпадающем списке (см. рис. 6). Такая реализация поля полезна, когда мы хотим предоставить пользователю в административной панели возможность выбора из предопределенного списка вариантов.

Рис. 6. Отображения поля suit в виде выпадающего списка.
Рис. 6. Отображения поля suit в виде выпадающего списка.

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

class PokerChips(models.Model):
    """Справочник покерных фишек для таблицы."""
    NOT_SUIT = 'NS'
    ONE_SUIT = 'OS'
    TWO_SUIT = 'TS'

    SUIT_CHOICES = [
        (NOT_SUIT, 'Масть не важна'),
        (ONE_SUIT, 'Одномастные'),
        (TWO_SUIT, 'Разная масть'),
    ]

    class Meta:
        verbose_name = 'Комбинация фишек'
        verbose_name_plural = 'Комбинации фишек'
        ordering = ('position', )

    name = models.CharField("Комбинация", max_length=2, default='')
    suit = models.CharField("Масть", max_length=2,
                            choices=SUIT_CHOICES, default=NOT_SUIT)
    position = models.PositiveIntegerField("Позиция в таблице")
    delta_x = models.IntegerField("Смещение текста по X", default=0)
    delta_y = models.IntegerField("Смещение текста по Y", default=0)

    def __str__(self):
        return f'{self.name} {self.suit}'

Предполагаем, что для одного стека будет несколько таблиц Hand Chart, так как стратегия может существенно отличаться при одинаковом размере стека. Поэтому описываем отдельную таблицу с привязкой к игровому стеку (GameStacks). Этот подход в будущем позволит расширить модель и настраивать каждую запись индивидуально, включая, например, установку цены за доступ. Модель назовем TableHandChart.

class TableHandChart(models.Model):
    """Таблица Нand Chart."""
    class Meta:
        verbose_name = 'Таблица Нand Chart'
        verbose_name_plural = '3. Таблицы Нand Chart'
        ordering = ('id', )

    name = models.CharField("Название таблицы", max_length=200, default='')
    stack = models.ForeignKey(GameStacks, on_delete=models.SET_NULL, verbose_name='Стек игры', null=True)
    description = models.TextField("Описание", max_length=2000, default='')
    is_active = models.BooleanField('Активна')

    def __str__(self):
        stack = self.stack.name if self.stack is not None else '__'
        return stack
		
		def save(self, *args, **kwargs):
				# вызов метода save родительского класса 
        super().save(*args, **kwargs)
        rows = ContentHandChart.objects.filter(table_id=self.id).all()
        if len(rows) == 0:
            chips = PokerChips.objects.all()
            for chip in chips:
                ContentHandChart.objects.create(table_id=self.id, chip=chip)

Добавим логику при сохранении модели. Для это расширим метод save - он вызывается при сохранении модели. Сначала вызываем метод save родителя, чтобы отработала запись в базу данных. После проверяем, что если нет данных в модели ContentHandChart которая агрегирует в себе данные для построения таблицы Hand Chart. Сделано, это для того чтобы в ручную не заполнять эту таблицу.

Рассмотри нашу основную модель ContentHandChart. Эта таблица содержит следующее:

  • table, ссылка на описание таблицы (модель TableHandChart)

  • chip, ссылка на конкретную комбинацию карт (модель PokerChips)

  • utg, ссылки на выбранные действия (модель OptionsAction) для позиции UTG. UTG — это аббревиатура, образованная от английского выражения Under The Gun, которое переводится как «под прицелом». Относится к ранним позиций за покерным столом, которые следуют сразу за блайндами — местами, на которых игроки ставят обязательные ставки вслепую.

  • utg1, ссылки на выбранные действия (модель OptionsAction) для позиции UTG1.

  • mp, ссылки на выбранные действия (модель OptionsAction) для позиции MP. В покере «MP» означает «Middle Position» или «среднюю позицию».

  • mp1, ссылки на выбранные действия (модель OptionsAction) для позиции MP1.

  • hj, ссылки на выбранные действия (модель OptionsAction) для позиции HJ. В покере «HJ» означает «Hijack». Это позиция за покерным столом, которая находится справа от «Cut‑off» и слева от «Button» в играх, где участвуют 6 или более игроков.

  • co, ссылки на выбранные действия (модель OptionsAction) для позиции CO. В покере «CO» означает «Cut‑off». Позиция «Cut‑off» считается одной из самых выгодных в Техасском Холдеме и других видах покера. Игрок в позиции «Cut‑off» действует предпоследним в большинстве раундов торговли (кроме первого раунда предфлопа), что позволяет ему собирать информацию о действиях большинства других игроков перед тем, как принять решение.

  • btn, ссылки на выбранные действия (модель OptionsAction) для позиции BTN. «BTN» или «Button» в покере обозначает позицию дилера. Это последняя и самая выгодная позиция в раунде ставок в Техасском холдеме. Преимущество этой позиции состоит в том, что игрок, сидящий на кнопке, действует последним после того, как все остальные игроки сделали свои ставки. Это дает ему возможность собрать максимум информации о действиях других игроков, прежде чем принять решение.

  • sb, ссылки на выбранные действия (модель OptionsAction) для позиции SB. В покере «SB» означает «Small Blind». Это одна из двух «слепых» ставок, которые игроки делают до начала раздачи в покере, таком как Техасский Холдем. Позиция Small Blind находится непосредственно слева от дилера (Button) и обязана сделать ставку, которая обычно составляет половину минимальной ставки или половину большого блайнда (Big Blind).

В код модели ContentHandChart выглядит следующим образом.

class ContentHandChart(models.Model):
    """Содержимое таблицы Hand Chart."""

    class Meta:
        verbose_name = 'Запись в таблице Hand Chart'
        verbose_name_plural = '4. Записи в таблице Hand Chart'
        ordering = ('id', )

    table = models.ForeignKey(TableHandChart, on_delete=models.CASCADE, verbose_name="Таблица Hand Chart",
                              null=True, related_name="table_content")
    chip = models.ForeignKey(PokerChips, on_delete=models.SET_NULL, verbose_name="Комбинация", null=True)
    utg = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция UTG", related_name='utg_color')
    utg1 = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция UTG1", related_name='utg1_color')
    mp = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция MP", related_name='mp_color')
    mp1 = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция MP1", related_name='mp1_color')
    hj = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция HJ", related_name='hj_color')
    co = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция CO", related_name='co_color')
    btn = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция BTN", related_name='btn_color')
    sb = models.ManyToManyField(OptionsAction, blank=True, verbose_name="Позиция SB", related_name='sb_color')
    update_date = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.chip.name

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

> python manage.py makemigrations

После выполнения в папке hand_chart, появится папка migrations в ней хранятся скрипты миграций. Применять миграции, нужно с помощью консольной команды:

> python manage.py migrate

В конечном итоге базу можно будет описать следующей схемой (рис. 7).

Рис. 7.  Схема базы данных таблиц которые используется в приложение hand_chart (Построено с помощью DBvisualizer)
Рис. 7. Схема базы данных таблиц которые используется в приложение hand_chart (Построено с помощью DBvisualizer)

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

# загружает примеры стеков игр
> python manage.py loaddata gamestacks

# загружает данные справочника комбинаций карт
> python manage.py loaddata pokerchips

Создание административной панели

Переходим к описанию классов для административной панели Django. Мы создадим интерфейс для выполнения операций создания, чтения, обновления и удаления (CRUD) над объектами моделей GameStacks, OptionsAction, PokerChips, TableHandChart. Пользователи смогут взаимодействовать с записями в базе данных, создавая, читая, обновляя и удаляя их через созданный нами интерфейс в административной панели.

Начнем с создания интерфейса для GameStacks. Это самый базовый интерфейс, который мы будем реализовывать. Мы создадим класс для административной панели Django, который будет наследовать admin.ModelAdmin. С помощью декоратора admin.register в Django Admin мы зарегистрируем модель, которую хотим отобразить на административной панели Django. В этом контексте мы регистрируем модель GameStacks и определяем набор и последовательность полей, которые будут отображаться на специальной странице.

from django.contrib import admin


@admin.register(models.GameStacks)
class GameStacksAdmin(admin.ModelAdmin):
    """Справочник стеков игры"""
    list_display = ('name', 'sort') # перечисляем поля которые будет выводиться на странице списка

После того как мы описали интерфейс для модели GameStacks. Он появится в административной панели (рис. 8.)

Рис. 8. Страница списка стеков игры (GameStacks) в административной панели Django
Рис. 8. Страница списка стеков игры (GameStacks) в административной панели Django

В интерфейсе для комбинаций карт (PokerChipsAdmin), страницу списка сделаем интерактивной. Добавив следующие функции:

  • в первую очередь выведем превью “фишки”, чтобы наглядно видить как влияют значения delta_x, delta_y на положение текста в фишке.

  • поля delta_x, delta_y - редактируемыми сразу из таблицы, такой подход упростит ситуацию когда нужно быстро сделать массовое редактирование.

Для отображения превью каждой записи в таблице создадим функцию (preview_chips), которая будет внедрять текст комбинации и корректировки позиции в шаблон фишки. В качестве аргумента, функция получает obj - это объект класса модели данных, в данном случае запись 1 комбинации карт из модели PokerChips. Такие функции в рамках класса ModelAdmin называются вычисляемыми полями. Можно использовать вычисляемые поля для отображения дополнительной информации об объектах модели или для отображения информации, которая производится путем обработки нескольких полей объекта модели.

В качестве шаблона фишки, используем svg-файл в нарисованной разметкой. В настройках проекта установлены две константы: SVG_X_TEXT и SVG_Y_TEXT, которые хранят координаты точки отсчета для позиционирования текста.

По умолчанию, Django автоматически экранирует все переменные, которые выводятся в шаблоне, чтобы предотвратить атаки с использованием межсайтового скриптинга (XSS). Это означает, что любые HTML-теги, содержащиеся в строке, будут отображаться как обычный текст, а не как HTML-код. Результат выполнения функции передаем в mark_safe. Эта функция в Django позволяет обозначить конкретную строку как "безопасную" для отображения в HTML-шаблоне без экранирования.

import os

from django.conf import settings
from django.contrib import admin
from django.utils.safestring import mark_safe


@admin.register(models.PokerChips)
class PokerChipsAdmin(admin.ModelAdmin):
    """Справочник комбинаций для таблицы"""
		
	list_display = ('name', 'suit', 'position', 'delta_x', 'delta_y', 'preview_chips') # список столбцов
    list_editable = ('delta_x', 'delta_y') # преодоствляет возможность массового редактирования в таблице
    readonly_fields = ('preview_chips',) # Поле доступно только для чтения
    fields = ('name', 'suit', 'position', 'delta_x', 'delta_y', 'preview_chips') # список полей на форме редактирования
    list_per_page = 20 # количество элементов на 1 странице

    def preview_chips(self, obj):
        init_x = settings.SVG_X_TEXT
        init_y = settings.SVG_Y_TEXT
        init_str = f'<text id="AT_1_" transform="matrix(1 0 0 1 {init_x} {init_y})"'

        new_x = init_x + obj.delta_x
        nex_y = init_y + obj.delta_y
        new_str = f'<text id="AT_1_" transform="matrix(1 0 0 1 {new_x} {nex_y})"'

        svg = open(os.path.join(settings.BASE_DIR, 'static/hand_chart/img/chip.svg'))
        data_svg = svg.read()
        # Замена координат
        data_svg = data_svg.replace(init_str, new_str, 1)
        # Замена текста комбинации
        data_svg = data_svg.replace('>AA<', f'>{obj.name}<', 1)

        return mark_safe(data_svg)

    preview_chips.short_description = "Превью фишки" # Таким образом мы задаем название столбца

Django Admin предлагает гибкие возможности для настройки интерфейсов, модифицируя значения свойств класса ModelAdmin. Например, для отображения превью фишки, добавляем имя функции в свойство list_display. В этом свойстве перечислены поля, которые будут представлены в виде столбцов на странице со списком. В этот список можно включить как поля модели, для которой создается интерфейс, так и вычисляемые поля - preview_chips. Указав кортеж в свойстве list_editable, определяем, какие поля можно будет редактировать в массовом порядке на странице со списком.

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

Рис. 9. Как выглядит вычисляемое поле preview_color (Превью цвета)
Рис. 9. Как выглядит вычисляемое поле preview_color (Превью цвета)

Второе поле, stacks, отображает список стеков, где используется данный вариант действия. Для отображения этого списка нужно пройти три этапа. На первом этапе мы делаем SQL-запрос, в котором выбираем все записи из ContentHandChart, где наш вариант действий встречается в позициях. Код запроса вынесен в константу SQL_GET_STACKS_BY_ACTION. Перед выполнением запроса вместо ‘%s’ подставляется id варианта действия. У таблицы ContentHandChart есть поле table_id, оно хранит id TableHandChart, его и укажем в списке выбираемых полей.

# Первый этап обработки данных
# hand_chart/sql.py
SQL_GET_STACKS_BY_ACTION = """
SELECT hand_chart_contenthandchart.table_id
FROM hand_chart_contenthandchart
    JOIN hand_chart_contenthandchart_utg utg on hand_chart_contenthandchart.id = utg.contenthandchart_id
    JOIN hand_chart_contenthandchart_utg1 utg1 on hand_chart_contenthandchart.id = utg1.contenthandchart_id
    JOIN hand_chart_contenthandchart_mp mp on hand_chart_contenthandchart.id = mp.contenthandchart_id
    JOIN hand_chart_contenthandchart_mp1 mp1 on hand_chart_contenthandchart.id = mp1.contenthandchart_id
    JOIN hand_chart_contenthandchart_hj hj on hand_chart_contenthandchart.id = hj.contenthandchart_id
    JOIN hand_chart_contenthandchart_co co on hand_chart_contenthandchart.id = co.contenthandchart_id
    JOIN hand_chart_contenthandchart_btn btn on hand_chart_contenthandchart.id = btn.contenthandchart_id
    JOIN hand_chart_contenthandchart_sb sb on hand_chart_contenthandchart.id = sb.contenthandchart_id
WHERE utg.optionsaction_id = %s or utg1.optionsaction_id = %s  or mp.optionsaction_id = %s  or mp1.optionsaction_id = %s
    or hj.optionsaction_id = %s or co.optionsaction_id = %s or btn.optionsaction_id = %s or sb.optionsaction_id = %s
GROUP BY hand_chart_contenthandchart.table_id
"""

# Это код выполняет запрос и собирает результат в список
with connection.cursor() as cursor:
    cursor.execute(SQL_GET_STACKS_BY_ACTION, [obj.id, obj.id, obj.id, obj.id, obj.id, obj.id, obj.id, obj.id])
    table_ids = [row[0] for row in cursor.fetchall()]

На втором этапе, делаем запрос с средствами ORM Django к модели TableHandChart. Выбираем все записи, с id полученные на первом этапе. В фильтр запрос передаем условие id__in=table_ids. Оператор __in в Django ORM является фильтрационным оператором. Он используется для фильтрации queryset по значениям, которые присутствуют в заданном списке. В контексте предоставленной кода, __in используется для фильтрации объектов TableHandChart, у которых id находится в списке table_ids.

# Второй этап обработки данных
# Запрос ORM Django к модели TableHandChart с использование select_related
tables = models.TableHandChart.objects.select_related('stack').filter(id__in=table_ids)

Метод select_related в Django ORM используется для оптимизации запросов к базе данных через предварительную выборку (или "жадную" выборку) связанных записей одного-к-одному и многие-к-одному. В данном примере, select_related('stack') заранее получает связанные объекты stack для каждого объекта TableHandChart, что позволяет избежать дополнительных запросов при обращении к связанному объекту stack каждого объекта TableHandChart.

В завершающем этапе, мы пройдемся через полученный список записей модели TableHandChart в цикле. Мы поместим название стека в тег <p> и соберем уникальный набор стеков игры с помощью функции set(), которая автоматически удаляет все дубликаты.

# Третий этап обработки данных
# Собираем список уникальных стеков
stacks = set()
for table in tables:
    stacks.add(f'<p>{table.stack.name}</p>')

Кроме уже описанных функциональных возможностей настройки интерфейса Django Admin - вроде вычисляемых полей, массового редактирования полей на странице со списком, управления списка полей в форме редактирования - существуют опции для определения собственных фильтров. Для этого, нужно просто передать в свойство list_filter кортеж с полями модели, для которых требуется фильтрация.

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

Чтобы создать настраиваемый фильтр, мы разработаем класс под названием StackFilter, который наследует от базового класса SimpleListFilter. Этот фильтр потребует определения двух методов:

  • Метод lookups, который вернёт все возможные варианты стеков игры.

  • Метод queryset, который подготавливает запрос к базе данных, применяя соответствующий фильтр, если он задан.

from django.contrib.admin import SimpleListFilter
from django.db import connection

import hand_chart.models as models

# hand_chart/sql.py
SQL_GET_ACTION_IDS_BY_STACK = """
SELECT utg.color_id, utg1.color_id, mp.color_id, mp1.color_id, hl.color_id, co.color_id, btn.color_id, sb.color_id
FROM hand_chart_tablehandchart
    JOIN hand_chart_contenthandchart on hand_chart_contenthandchart.table_id=hand_chart_tablehandchart.id
    JOIN hand_chart_contenthandchart_utg utg on hand_chart_contenthandchart.id = utg.contenthandchart_id
    JOIN hand_chart_contenthandchart_utg1 utg1 on hand_chart_contenthandchart.id = utg1.contenthandchart_id
    JOIN hand_chart_contenthandchart_mp mp on hand_chart_contenthandchart.id = mp.contenthandchart_id
    JOIN hand_chart_contenthandchart_mp1 mp1 on hand_chart_contenthandchart.id = mp1.contenthandchart_id
    JOIN hand_chart_contenthandchart_hj hj on hand_chart_contenthandchart.id = hj.contenthandchart_id
    JOIN hand_chart_contenthandchart_co co on hand_chart_contenthandchart.id = co.contenthandchart_id
    JOIN hand_chart_contenthandchart_btn btn on hand_chart_contenthandchart.id = btn.contenthandchart_id
    JOIN hand_chart_contenthandchart_sb sb on hand_chart_contenthandchart.id = sb.contenthandchart_id
WHERE hand_chart_tablehandchart.stack_id=%s
"""

# hand_chart/admin/filter.py
class StackFilter(SimpleListFilter):
    title = 'Стеки игры' # названия фильтра
    parameter_name = 'stack2' # название GET-параметра

    def lookups(self, request, model_admin):
				"""Возвращает все возможные варианты."""
        stacks = models.GameStacks.objects.all()
        return [(s.id, s.name) for s in stacks]

    def queryset(self, request, queryset):
        if self.value() == 'ALL':
            return queryset
        if self.value():
            color_ids = []
            with connection.cursor() as cursor:
                cursor.execute(SQL_GET_ACTION_IDS_BY_STACK, [self.value()])

                for row in cursor.fetchall():
                    color_ids += [row[i] for i in range(0, 8)]
            return queryset.filter(id__in=set(color_ids))

В методе queryset мы осуществляем проверку: если установлено значение фильтра ALL - это кнопка "все" в фильтре, которая по умолчанию включена в список вариантов. Если условие на ALL не срабатывает, мы выполняем SQL-запрос SQL_GET_ACTION_IDS_BY_STACK. В этом запросе выбираются записи TableHandChart, в которых присутствует значение фильтруемого игрового стека. Затем выбираются id действий, связанных с любой из позиций в таблице ContentHandChart. В SQL-запросе имеется большое количество конструкций JOIN, что обусловлено тем, что связь между позицией и вариантом действий является связью типа "МНОГИЕ КО МНОГИМ". При использовании такого типа связей создается отдельная таблица, в которой хранятся записи с идентификаторами обеих связанных сущностей.

for row in cursor.fetchall():
    color_ids += [row[i] for i in range(0, 8)]

Проходя в цикле по столбцам, соответствующим конкретной позиции, преобразуем матрицу nx8 в список id. В завершающей строке к набору запросов добавляем фильтр, используя уникальные id с помощью оператора __in.

С учетом всех вышеописанных разработок, код для фильтра и SQL отделены в отдельные модули (файлы .py). Код интерфейса для OptionsActionAdmin примет следующий вид.

import os

from django.conf import settings
from django.contrib import admin
from django.utils.safestring import mark_safe

import hand_chart.models as models
from hand_chart.admin.filter import StackFilter
from hand_chart.sql import SQL_GET_STACKS_BY_ACTION


@admin.register(models.OptionsAction)
class OptionsActionAdmin(admin.ModelAdmin):
    """Справочник вариантов ответов с цветами"""
    list_display = ('name', 'preview_color', 'stacks', 'description')
    list_filter = (StackFilter,)

    def preview_color(self, obj):
        html = f'<div style="background-color: {obj.color};width: 50%;height: 20px;text-align: center;' \
               f'padding-top: 4px;">{obj.color}</div>'
        return mark_safe(html)

    def stacks(self, obj):
        with connection.cursor() as cursor:
            cursor.execute(SQL_GET_STACKS_BY_ACTION, [obj.id, obj.id, obj.id, obj.id, obj.id, obj.id, obj.id, obj.id])
            table_ids = [row[0] for row in cursor.fetchall()]
        tables = models.TableHandChart.objects.select_related('stack').filter(id__in=table_ids)
        stacks = []
        for table in tables:
            stacks.append(f'<p>{table.stack.name}</p>')
        html = ''.join(stacks)
        return mark_safe(html)

    preview_color.short_description = "Превью цвета"
    stacks.short_description = "Стеки игры"

Создание своей страницы

На примере выше описанных интефейсов для моделей PokerChipsAdmin, OptionsActionAdmin проиллюстрировал возможности гибкой настройки Django Admin. Но для того чтобы сделать полностью свою страницу в аминистративной панели - используем proxy модели.

Proxy модель – это специальный тип модели в Django, который позволяет создавать "обертки" над существующими моделями без изменения оригинальной структуры базы данных. Это отличный способ добавить дополнительные поля, методы или параметры фильтрации к существующим моделям.

Нашу proxy модель будем строить на основе ContentHandChart - так как, она содержит все необходимые данные для построения Hand Chart из примера (рис. 4.)

Для создания proxy модели в Django, необходимо создать новый класс, унаследованный от оригинальной модели, и добавить все необходимые изменения. В нашем примере, класс PreviewHandChart будет унаследован от модели ContentHandChart.

class PreviewHandChart(ContentHandChart):
    """Отображение таблицы Hand Chart."""

    class Meta:
        proxy = True # признак proxy модели
        verbose_name = "Hand Chart"
        verbose_name_plural = "Hand Chart"
        ordering = ('id', )

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

Django автоматически просматривает каталог "templates" в каждом из приложений проекта, в поисках файлов шаблонов. При этом, для удобства организации шаблонов, можно создавать подкаталоги внутри каталога "templates". В этом контексте, файл шаблона располагается по адресу hand_chart/templates/admin/preview_hand_chart_change_list.html. Разместим шаблон в подкаталоге "admin" с целью отделить от остальных шаблонов.


from django.contrib import admin

import hand_chart.models as models
from hand_chart.admin.form import ContentHandChartForm


@admin.register(models.PreviewHandChart)
class PreviewRyeRangeAdmin(admin.ModelAdmin):
    form = ContentHandChartForm
    change_list_template = 'admin/preview_hand_chart_change_list.html'

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

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

Рис. 10. Встроенная HTML-страница в Django Admin
Рис. 10. Встроенная HTML-страница в Django Admin

Наследовать будем шаблон admin/change_list.html - это шаблон списочной страницы для интефейса в Django Admin. Таким образом можно от наследоваться от любого шаблона админ панели, и расширять его.

# прописываем в самом начале admin/preview_hand_chart_change_list.html
{% extends "admin/change_list.html" %} 

Система шаблонов Django поддерживает использование блоков для структурирования шаблона и повторного использования частей кода, так называемую "блочную структуру" шаблонов. Блочная структура шаблонов в Django - это мощный механизм, который позволяет определить области или "блоки" в базовом шаблоне, которые могут быть переопределены в дочерних шаблонах.

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

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

{% extends "admin/change_list.html" %}
<!-- Определяем блок с заголовком на странице --!>
{% block content_title %}
    <h1> Таблица Hand Chart </h1>
{% endblock %}

{% block result_list %}
  <!-- Здесь будет наш код формирующий HTML страницу --!>
{% endblock %}

В силу того, что таблиц Hand Chart у нас несколько, мы не будем делать делать страницу под каждую таблицу. Сделаем 1 страницу, но с возможностью выбора таблицы Hand Chart в списке.

<select name="table_id" >
    {% if tables|length == 0 %}
        <option>---------</option>
    {% endif %}
    {% for item in tables %}
        {% if request.POST.table_id != None %}
            <option value="{{ item.id }}" {% if item.id|stringformat:"i" == request.POST.table_id %}selected{% endif %}>{{ item.name }} (Стэк: {{ item.stack }})</option>
        {% elif request.GET.table_id != None %}
            <option value="{{ item.id }}" {% if item.id|stringformat:"i" == request.GET.table_id %}selected{% endif %}>{{ item.name }} (Стэк: {{ item.stack }})</option>
        {% else %}
            <option value="{{ item.id }}" >{{ item.name }} (Стэк: {{ item.stack }})</option>
        {% endif %}
    {% endfor %}
</select>

У элемента select устанавливаем атрибут name как table_id. При отправке формы методом GET, в параметрах запроса будет добавлен параметр "table_id" со значением выбранной опции.

Переменная tables, переданная в шаблон, содержит список таблиц, доступных для отображения. Функция |length в шаблонизаторе аналогична функции len() в Python. Если список пуст, элемент select заполняется одним дефолтным option.

Проходим циклом по списку таблиц и проверяем следующие условия:

  • Пытаемся извлечь параметр table_id из POST-запроса. Если такой параметр отсутствует, его значение будет None.

  • Также пытаемся извлечь параметр table_id из GET-запроса. Если такой параметр отсутствует, его значение будет None.

  • Если первые два условия не сработали, просто выводим option. В качестве значения используем id записи, а текст формируется из названия и стека игры.

Если параметр table_id задан, значит у option должен быть указан атрибут selected. Это означает, что данный вариант выбран. Это делается для того, чтобы после перезагрузки страницы в select был выбран нужный вариант. В request.POST.table_id значение хранится в виде строки, поэтому используем функцию |stringformat:"i". Функция |stringformat:"i" в шаблонизаторе Django применяется для преобразования числового значения в строку, представляя его как целое число.

Переменная result хранит список записей модели ContentHandChart. В цикле проходимся по списку, и выводим фишки в виде svg. В завимости от значения suit мы будем менять css-класс, по аналогии как проставляли атрибут selected у тега option.

В предыдущих разделах упомянули, что разные буквы обладают разной шириной, что требует коррекции позиционирования текста внутри внутреннего круга фишки. Для осуществления этой коррекции мы используем функцию |add: в строке {{ 142.9828|add:value.chip.delta_x }}. В контексте шаблонизатора Django, функция |add применяется для сложения двух значений. Например, {{ value|add:"2" }} увеличит значение переменной value на 2. Это не ограничено только числовыми значениями: если оба аргумента являются строками, функция |add выполнит их конкатенацию.

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

{% extends "admin/change_list.html" %}
{% load chips_tag %} <!-- так мы импортируем набор наших тегов ---!>
{% load mathfilters %} <!-- так мы импортируем функции |add ---!>
{% block content_title %}
    <h1> Таблица Hand Chart </h1>
{% endblock %}
{% block result_list %}

В корне нашего приложения hand_chart, мы создаем папку под названием templatetags. Внутри этой папки, мы создаем модуль с именем chips_tag.py. Имя этого файла соответствует имени, которое мы будем использовать при импорте. В этом файле мы опишем функцию, которая будет использоваться в шаблоне. Эта функция будет контролировать вывод секторов для каждой позиции, а также будет устанавливать css-класс, который окрашивает сектор в цвет выбранного действия. В рамках данной статьи мы не будем подробно разбирать код этой функции, так как он достаточно прост. Вы можете ознакомиться с его полной версией в репозитории, прилагаемом к этой статье.

# файл hand_chart/templatetags/chips_tag.py
from django import template

register = template.Library()

@register.simple_tag
def selector_color_v2(qs, is_cache=False):
		...
	# здесь будет код, исходник можно посмотреть в репозитории



# В файле admin/preview_hand_chart_change_list.html вызов нашего тега выглядит так
<g id="colors">
    {% selector_color_v2 value %}
    <ellipse id="center_1_" class="{% if value.chip.suit == 'NS' %}inner_circle_NS{% elif value.chip.suit == 'OS' %}inner_circle_OS{% else %}inner_circle_TS{% endif %}" cx="200" cy="200" rx="88" ry="87.9"/>
    <text id="AT_1_" x="58" y="0" text-anchor="middle" transform="matrix(1 0 0 1 {{ 142.9828|add:value.chip.delta_x }} {{ 236.9528|add:value.chip.delta_y }})" class="{% if value.chip.suit == 'NS' %}text_NS{% elif value.chip.suit == 'OS' %}text_OS{% else %}text_TS{% endif %} st4 st5 st6">{{ value.chip.name }}</text>
</g>

В функции get_queryset мы готовим исходные данные. Как было ранее объяснено, queryset представляет собой подготовленный запрос. В данном случае мы пытаемся извлечь параметр table_id из данных запроса и используем его для фильтрации значений модели ContentHandChart.

@admin.register(models.PreviewHandChart)
class PreviewRyeRangeAdmin(admin.ModelAdmin):
    form = ContentHandChartForm
    change_list_template = 'admin/preview_hand_chart_change_list.html'

		def get_queryset(self, request):
        queryset = models.ContentHandChart.objects

        if request.method == 'GET':
            table_id = request.GET.get('table_id', None)
        else:
            table_id = request.POST.get('table_id', None)
            if table_id is None:
                table_id = request.GET.get('table_id', None)

        if not table_id:
            table = models.TableHandChart.objects.filter(is_active=True).first()
            if table:
                table_id = table.id

        if table_id:
            queryset = models.ContentHandChart.objects.filter(table_id=table_id)
        return queryset

Когда входите в страницу списка в Django Admin, Django использует функцию changelist_view для построения страницы. Эта функция получает HTTP-запрос, а затем возвращает HTTP-ответ, который обычно является HTML-страницей, отображающей список объектов.

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

  • tables, список записей модели TableHandChart

  • result, список записей модели ContentHandChart, это наши “фишки”

  • colors_class, список записей модели OptionsAction, все возможные варианты, на основе них делаем css-классы

@admin.register(models.PreviewHandChart)
class PreviewRyeRangeAdmin(admin.ModelAdmin):
		...

    def changelist_view(self, request, extra_context=None):
		# Вызываем метод changelist_view, для родительского класса, чтобы получить исходный ответ. 
        response = super().changelist_view(
            request,
            extra_context=extra_context,
        )
				
		# Из переменой response пробуем получить, queryset.
        try:
            qs = response.context_data['cl'].queryset
        except (AttributeError, KeyError):
            return response

		# Если записей нет в базе, возвращаем пустые значения
        tables = models.TableHandChart.objects.filter(is_active=True).all()
        if len(tables) == 0:
            response.context_data['tables'] = []
            response.context_data['result'] = []
            response.context_data['colors_class'] = []

            return response

		# если записи есть, сохраняем значение в контексте
		response.context_data['tables'] = tables

В Django Admin, cl является сокращением от "ChangeList", что обозначает объект списка изменений. Этот объект создаётся при каждом отображении страницы администрирования и содержит всю информацию, необходимую для отображения этой страницы.

response.context_data['cl'].queryset — это способ получить доступ к QuerySet, который был использован для генерации этой страницы. Он предоставляет доступ к тому же набору объектов, что и отображение на самой странице.

Используя queryset, мы извлекаем список всех фишек из ContentHandChart, которые связаны с определённой таблицей. Почему именно с определённой таблицей? Потому что, в методе get_queryset мы ранее установили логику добавления фильтра по id таблицы. Этот полученный список нам нужно преобразовать в матрицу размером 13x13. Полученную матрицу затем сохраняем в контексте для последующего использования в шаблоне.

rows = qs.all()

i = 0
result = []
temp = []
for row in rows:
    if i < 12:
        temp.append(row)
        i += 1
    else:
        temp.append(row)
        result.append(temp)
        i = 0
        temp = []

response.context_data['result'] = result

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

rows = models.OptionsAction.objects.all()
colors = []
for row in rows:
    colors.append('.color_'+str(row.id)+'_sector{fill:'+row.color+';}')
response.context_data['colors_class'] = colors

Зайдем в административную часть, и выбираем пункт меню “Hand Chart”. В результате выполнения метода changelist_view, отобразится не стандартная списочная страница Hand Chart (рис. 11.)

Рис. 11. Результат работы интерфейса на основе proxy - модели PreviewHandChart
Рис. 11. Результат работы интерфейса на основе proxy - модели PreviewHandChart

При клике на любую фишку открывается форма редактирования записи. В обычной форме нет валидатора, который проверяет условие что в конкретной позиции может быть выбрано максимум 2 варианта действий. Чтобы ввести такую валидацию, переопределяем форму. В свойстве form, класса PreviewRyeRangeAdmin указывает класс описывающий новую форму редактирования записи ContentHandChartForm.

from django import forms

import hand_chart.models as models


class ContentHandChartForm(forms.ModelForm):
    class Meta:
        model = models.ContentHandChart
        fields = '__all__'

    def clean_utg(self):
        utg = self.cleaned_data['utg']
        if len(utg) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return utg

    def clean_utg1(self):
        utg1 = self.cleaned_data['utg1']
        if len(utg1) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return utg1

    def clean_co(self):
        co = self.cleaned_data['co']
        if len(co) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return co

    def clean_hl(self):
        hl = self.cleaned_data['hl']
        if len(hl) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return hl

    def clean_mp(self):
        mp = self.cleaned_data['mp']
        if len(mp) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return mp

    def clean_mp1(self):
        mp1 = self.cleaned_data['mp1']
        if len(mp1) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return mp1

    def clean_btn(self):
        btn = self.cleaned_data['btn']
        if len(btn) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return btn

    def clean_sb(self):
        sb = self.cleaned_data['sb']
        if len(sb) > 2:
            raise forms.ValidationError(
                "Максимум можно выбрать только 2 варианта.")
        return sb

Класс формы наследуем от ModelForm и задаем два свойства:

  • model, модель которая используется как источник данных

  • fields, список полей, в данном случае ‘__all__’ - означает все поля из модели.

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

Проведения валидации определенного поля, через создания методов класса, следуя паттерну “clean_<имя поля>”. Метод активируется после основного этапа валидации полей. В рамках этого метода класса проверяем, что количество выбранных вариантов действий не превышает два. В случае превышения, мы возбуждаем исключение ValidationError. Исключение будет обработано ModelForm, и при попытке сохранить форму с некорректными данными, поле подсветится красным, и будет выведена ошибка (рис. 12).

Рис. 12. Пример как выглядит ошибка валидации формы.
Рис. 12. Пример как выглядит ошибка валидации формы.

Помимо валидации, на странице с формой желательно реализовать следующие возможности:

  • скрыть поля table - привязка к конкретной таблице и chip - привязка к фишке из справочника. Поля в данном конеткте, являются служебными и не должны показываться пользователю.

  • отключить возможности добавления, просмотра, удаления. На форме эти возможности реализуются в виде дополнительных кнопок (рис. 13.). Необходимо отставить только кнопку “Сохранить”.

  • изменить заголовок таблицы, сделав его более информативным.

Рис. 13. Дополнительные кнопки в форме редактирования любой записи в административной панели
Рис. 13. Дополнительные кнопки в форме редактирования любой записи в административной панели

Реализовать выше указанные требования, можно переопределив метод changeform_view класса PreviewRyeRangeAdmin. Метод changeform_view в Django предназначен для обработки страницы редактирования объекта в административном интерфейсе Django. Он отвечает за обработку запросов на этих страницах и включает в себя функциональность для обработки формы редактирования, а также валидации данных формы.

@admin.register(models.PreviewHandChart)
class PreviewRyeRangeAdmin(admin.ModelAdmin):
    ....

    def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
		# Вызываем метод changelist_view, для родительского класса, чтобы получить исходный ответ. 
        response = super().changeform_view(request, object_id, form_url, extra_context)
				
		# Проверяем response это не HttpResponseRedirect - редирект, c формы авторизации
		# Такая ситуация может вознинуть если нет прав на редактрование
        if response.__class__.__name__ != 'HttpResponseRedirect':
			# Здесь меняем заголовок на форме
            response.context_data['title'] = 'Изменение комбинации '+response.context_data['original'].chip.name + \
                ' ('+response.context_data['original'].chip.get_suit_display()+')'
			# Делаем запрет, на удаление, добавление, и просмотр
            response.context_data['has_delete_permission'] = False
            response.context_data['has_add_permission'] = False
            response.context_data['has_view_permission'] = False
			
            # У полей table и chip, меняет виджет для отображения на скрытое поле ввода 
            response.context_data['adminform'].form.fields['table'].widget = forms.HiddenInput(
            )
            response.context_data['adminform'].form.fields['chip'].widget = forms.HiddenInput(
            )
        else:
            url = urlparse(request.META.get('HTTP_REFERER'))
            url = response.url+'?'+url.query
            return redirect(url)

        return response

Выводы

Подводя итог, хочу отметить, что статья получилась довольно объемной, но я надеюсь, что она поможет прояснить некоторые аспекты работы с Django и Django Admin, включая:

  • вычисляемые поля;

  • настройку и создание собственных фильтров;

  • добавление дополнительной логики валидации в формы;

  • основы работы с шаблонами в Django;

  • создание собственной страницы в админ-панели.

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

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


  1. danilovmy
    08.09.2023 22:31

    Ребят, вам бы программиста. А то сил нет это видеть. Ну что в форме 8-кратная копипаста, неужели не ёкнуло ни у кого? Ну или что в stacks несколько запросов в базу на один объект. Или один и тот же файл 100 раз открываете - у вас кеширование результатов не изобрели что ли? А RAW sql в коде админстратора? Ну прям совсем нетестируемо не читаемо и неподерживаемо. Я не сомневаюсь, что работает. Но в данных примерах это работает не потому что, а вопреки.


    1. MyShinobi Автор
      08.09.2023 22:31

      Спасибо за ваше замечание и за то, что обратили на это внимание!

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

      Однако, было бы замечательно, если бы обратная связь была выражена более конструктивно. Уважительное общение помогает всем нам сделать IT-сообщество лучше. Спасибо за понимание!