Django: категории, субкатегории и форма их добавления для админки

Разрабатывая информационную систему для дипломной работы, столкнулся с такой трудностью: Как организовать создание истанса основной модели (код ниже) одновременно с категориями и субкатегориями к ней, Если отношение Foreignkey у 3 этих моделей имеет вид "Document <- category -> subcategory" - Документ ссылается на категорию, а субкатегория ссылается на категорию (Это было сделано для того, чтобы субкатегории не пересекались друг с другом в разных категориях. Т.е набор субкатегорий для каждой категории уникален (относительно). 1 Document instance = 1 категория с 1 субкатегорией к ней.) Возможно эта идея с моей стороны очень глупа, но пока что я не додумался до чего-то лучшего. MultiForeignKey я не принял по причине, что мне, во-первых, не нужна множественная вложенность (только 1-уровневая) и, во-вторых, не подходит структура.

Сам вопрос: валидна ли построенная мною структура? Если нет, подскажите библиотеку или паттерн, которые подойдут под мою ситуацию. Если да, помогите с формой для Document и (если можно) с выводом через list_display субкатегории.

Код:

# models.py
class Document(models.Model):
    """ Класс модели документа.
    
    Документ - любой текстовый файл. В основном, под ними подразумеваются научные работы,
    контрольные, лабораторные, курсовые и дипломные работы, эссе, лекции, методички, сочинения
    и так далее.
    """
    DOCUMENT_TYPE = [
        (0, 'Не выбран'),
        (1, 'Реферат'),
        (2, 'Дипломная работа'),
        (3, 'Магистерская диссертация'),
        (4, 'Отчет по практике'),
        (5, 'Курсовая работа'),
        (6, 'Курсовая практика'),
        (7, 'Практическая работа'),
        (8, 'Эссе'),
        (9, 'Доклад'),
        (10, 'Лекция'),
        (11, 'Методичка'),
        (12, 'Сочинение'),
        (13, 'Контрольная работа'),
    ]
    
    title = models.CharField(max_length=255, unique=True, verbose_name="Заголовок")
    abstract_ru = models.TextField(verbose_name="Аннотация на русском")
    abstract_en = models.TextField(null=True, blank=True, verbose_name="Аннотация на английском")
    slug = models.SlugField(null=True, max_length=300)
    document_type = models.IntegerField(default=0, choices=DOCUMENT_TYPE, verbose_name='Тип')
    category = models.ForeignKey("Category", verbose_name="", on_delete=models.PROTECT)
    file = models.FileField(upload_to=custom_upload_to, verbose_name="Файл")
    file_format = models.CharField(
        null=True,
        blank=True,
        max_length=10,
        verbose_name="Формат файла"
    )
    publisher = models.ForeignKey(
        get_user_model(),
        verbose_name="Автор",
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
    )
    uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата загрузки")
    updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата изменения")
    published_at = models.DateTimeField(blank=True, null=True, verbose_name="Дата публикации")
    is_published = models.BooleanField(default=False, verbose_name="Опубликовано?")

    def get_absolute_url(self):
        return reverse("bibliofund/document-detail", kwargs={"slug": self.slug})

    def __str__(self) -> str:
        return self.title
    
    def __repr__(self) -> str:
        template = '<Document title="{0}" publisher="{1}" type="{2}">'
        return template.format(
            self.title[:20],
            self.publisher.username or "anon",
            Document.DOCUMENT_TYPE[self.document_type][1]
        )

    class Meta:
        verbose_name = "Документ"
        verbose_name_plural = "Документы"
        ordering = ['updated_at']


class Category(models.Model):
    """ Класс модели категории документа c одним уровнем вложенности.
    
    ...
    """
    name = models.CharField(max_length=200)
    slug = models.SlugField()

    class Meta:
        verbose_name = 'Категория'
        verbose_name_plural = "Категории" 


class Subcategory(models.Model):
    """ Класс модели подкатегории.
    
    Способ соединения child -> parent был выбран специально, чтобы сделать one-to-many field для 
    категорий: Много субкатегорий первого уровня вложенности к одной корневой категории. А самих
    категорий может быть много.
    """
    name = models.CharField(max_length=200)
    slug = models.SlugField()
    parent = models.ForeignKey(
        to='Category',
        blank=True,
        null=True,
        related_name='parent',
        on_delete=models.CASCADE
    )

    class Meta:
        verbose_name = 'Субкатегория'
        verbose_name_plural = "Субкатегории"


@receiver(pre_save, sender=Document)
def presave_fields(sender, instance, *args, **kwargs):
    """Обычный сигнал для добавления слага перед сохранением, изменения времени публикации и
    добавление формата документа.

    Не использую стандартный встроенный в django slugify, потому что он не работает с кириллицей.
    """
    if instance.is_published and instance.published_at is None:
        instance.published_at = timezone.now()
    elif not instance.is_published and instance.published_at is not None:
        instance.published_at = None
    
    if not instance.file_format and instance.file:
        instance.file_format = instance.file.name.split('.')[-1]
        instance.old_format = instance.file_format
    if instance.file_format != instance.old_format:
        instance.file_format = instance.file.name.split('.')[-1]
    
    instance.slug = slugify(instance.name)

# admin.py
from django.contrib import admin

from .models import Document, Category, Subcategory

# Register your models here.
class CommonAdmin(admin.ModelAdmin):
    # TODO: форма создания инстанса модели Document с категорией и субкатегорией.
    list_display = (
        "id",
        "title",
        "document_type",
        "category"
        # TODO: вывод category__subcategory. Пока что не понимаю, как это сделать.
    )
    # list_display_links = ()
    # search_fields = ()
    # list_filter = ()

admin.site.register(Document, CommonAdmin)
admin.site.register(Category)
admin.site.register(Subcategory)
Вернуться на верх