Files
2018-lpon-site/lpon_site/frontend/utils.py

638 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frontend/utils.py
# Служебные функции и хелперы проекта
import re
import pytils
import random
import logging
from bs4 import BeautifulSoup
from html import unescape
from etpgrf.config import HANGING_PUNCTUATION_SPACE_CHARS as SPACE_CHARS
from django.core.exceptions import ValidationError
from lpon_site.settings import (
SLUG_MAX_LENGTH, KEY_SYNONYM,
VALIDATE_KEY__MATCH_TYPE, VALIDATE_KEY__MODEL, VALIDATE_KEY__VALUE,
ValidateMatchType
)
logger = logging.getLogger(__name__)
def normalize_string(s: str) -> str:
"""
Нормализует строку: удаляет невидимые символы, начальные, конечные и дублирующие пробелы.
Работает со ВСЕМИ типами пробельных символов (не-breaking space, thin space и т.д.).
Args:
s: Строка для нормализации
Returns:
str: Нормализованная строка (или пустая если была пуста)
Пример:
>> normalize_string(" Sony Music ")
'Sony Music'
>> normalize_string("Sony\u00a0\u202FMusic") # с неразрывными пробелами
'Sony Music'
"""
if not s:
return ""
result = str(s)
# Удаляем невидимые символы (не заменять, а полностью удалять)
result = result.translate({
ord("\xad"): None, # символ мягкого переноса
ord("\u200b"): None, # символ нулевой ширины (zero-width space)
ord("\u200c"): None, # символ нулевой ширины (zero-width non-joiner)
ord("\u200d"): None, # символ Zero Width Joiner (ZWJ)
ord("\u2060"): None, # символ Word Joiner (WJ)
ord("\ufeff"): None, # символ Zero Width No-Break Space (BOM)
})
# Все типы пробельных символов для замены на обычный пробел
all_spaces = SPACE_CHARS | frozenset([
"\u00a0", # non-breaking space (&nbsp)
"\u202F", # narrow no-break space (тонкий неразрывный пробел)
])
# Заменяем ВСЕ типы пробелов на обычный пробел
for space_char in all_spaces:
result = result.replace(space_char, " ")
# Удаляем начальные/конечные пробелы и нормализуем множественные пробелы
# Финальная подстраховка: regex для ВСЕХ unicode whitespace символов (даже неизвестных)
result = re.sub(r'\s+', ' ', result).strip()
return result
def safe_html_special_symbols(s: str) -> str:
"""Преобразует HTML-фрагмент в чистый текст.
Удаляет все HTML-теги и декодирует HTML-мнемоники в Unicode.
Затем нормализует пробелы через normalize_string().
Args:
s: Строка, которую надо очистить (с возможной HTML-разметкой).
Returns:
str: Чистый текст без HTML-разметки, спецсимволов и нормализованный.
Example:
>> safe_html_special_symbols('<p>Привет&nbsp;<b>мир</b>!</p>')
'Привет мир!'
>> safe_html_special_symbols('Текст с\\u00a0неразрывным и \\u202Fтонким пробелом')
'Текст с неразрывным и тонким пробелом'
"""
if not s:
return ""
try:
soup = BeautifulSoup(s, "html.parser")
except Exception as e:
logger.warning(f"BeautifulSoup parse error, using raw string: {e}")
return str(s)
# Скрипты и стили в чистый текст не нужны — выкидываем их целиком.
for tag in soup(["script", "style", "noscript", "code", "kbd", "pre"]):
tag.decompose()
result = soup.get_text()
result = unescape(result)
# Нормализуем: удаляем невидимые символы, все типы пробелов, дубли и края
return normalize_string(result)
def make_slug(slug_it: str, max_length: int | None = None, slug_default: str = "content") -> str:
"""Готовит чистый slug из HTML/Unicode текста (включая русский текст).
Преобразует текст в URL-friendly slug:
- Удаляет HTML-теги
- Транслитерирует русский текст
- Удаляет спецсимволы
- Обрезает до max_length
- Гарантирует уникальность (fallback на случайное значение)
Args:
slug_it: Строка для преобразования в slug.
max_length: Максимальная длина slug (по умолчанию из settings).
slug_default: Значение по умолчанию, если slug пустой после обработки.
Returns:
str: Чистый slug, готовый для использования в URL.
Example:
>> make_slug('<b>The Beatles</b>')
'the-beatles'
>> make_slug('Какой-то текст')
'kakoj-to-tekst'
"""
if not slug_it:
return f"{slug_default}-{random.randint(1, 4095):03x}"
max_length = max_length or SLUG_MAX_LENGTH
# Вычисляем минимальную длину fallback'а: "slug_default-xyz"
min_fallback_length = len(slug_default) + 1 + 3 # "-" и 3 hex-символа
# Очищаем текст от HTML и спецсимволов
clean_text = safe_html_special_symbols(slug_it).lower()
# Транслитерируем и создаем slug (pytils подходит для русского)
slug = pytils.translit.slugify(clean_text)
# Нормализуем множественные дефисы, удаляем дефисы в начале/конце
slug = re.sub(pattern=r"-+", repl="-", string=slug).strip("-")
# Обрезаем излишнее (но только если это не нарушит fallback)
# Если max_length недостаточен для slug_default, не обрезаем
if max_length >= min_fallback_length:
slug = slug[:max_length]
# Если все еще пусто — генерируем fallback (БЕЗ обрезания!)
return slug or f"{slug_default}-{random.randint(1, 4095):03x}"
def validate_for_duplicates(
model_class,
instance_pk: int | None,
main_field_value: str,
metadata_dict: dict | None,
main_field_name: str | None = None,
metadata_field_name: str | None = None,
) -> dict:
"""
Универсальный валидатор для проверки дубликатов в моделях БД.
Находит дубликаты и возвращает их список.
Логика обработки (исключение, логирование, API ответ) — дело вызывающего кода.
Args:
model_class: Класс модели для поиска (TbLabel, TbArtist и т.д.). Обязателен!
instance_pk: PK текущей записи или None для новых записей
main_field_value: Значение основного поля для проверки (s_label, s_artist и т.д.). Не может быть пусто!
metadata_dict: Словарь метаданных, содержащий синонимы. Может быть None или {}
main_field_name: Имя основного поля модели (s_label, s_artist, s_style_name). Обязателен!
metadata_field_name: Имя поля метаданных (j_label_metadata, j_artist_metadata). Обязателен!
Returns:
list: Список найденных дубликатов (может быть пустой)
Каждый элемент: {'pk': int, 's_label': str, 'matched_value': str, 'match_type': str, ...}
Примеры использования:
# В админке
duplicates = validate_for_duplicates(
model_class=TbLabel,
instance_pk=self.instance.pk,
main_field_value=self.cleaned_data['s_label'],
metadata_dict=self.instance.j_label_metadata,
main_field_name='s_label',
metadata_field_name='j_label_metadata',
)
if duplicates:
raise ValidationError("Найдены дубликаты...")
# В парсере
duplicates = validate_for_duplicates(...)
if duplicates:
logger.warning(f"Дубликаты найдены: {duplicates}")
continue
"""
# ===== ВАЛИДАЦИЯ ПАРАМЕТРОВ =====
# Проверяем, что model_class и имена полей переданы
if model_class is None:
raise TypeError("model_class is required and cannot be None")
if main_field_name is None:
raise TypeError(
"main_field_name is required and cannot be None.\nExample: 's_label', 's_artist', 's_style_name'"
)
if metadata_field_name is None:
raise TypeError(
"metadata_field_name is required and cannot be None.\nExample: 'j_label_metadata', 'j_artist_metadata'"
)
# Проверяем, что поля существуют в модели
for field_name in [main_field_name, metadata_field_name]:
if not hasattr(model_class, field_name):
raise AttributeError(
f"Model '{model_class.__name__}' has no field '{field_name}'"
)
# Проверяем main_field_value (не может быть пусто даже после нормализации)
if not main_field_value or not str(main_field_value).strip():
raise ValidationError("main_field_value cannot be empty or whitespace")
# Нормализуем основное поле (удаляем пробелы в начале/конце и дублирующие)
normalized_main_value = normalize_string(main_field_value)
# Проверяем, что после нормализации остался какой-то текст
if not normalized_main_value:
raise ValidationError("main_field_value becomes empty after normalization")
# Проверяем metadata_dict (если передан, должен быть dict или None)
if metadata_dict is not None and not isinstance(metadata_dict, dict):
raise TypeError(
f"metadata_dict must be dict or None, got {type(metadata_dict).__name__}"
)
# ===== ОСНОВНАЯ ЛОГИКА =====
duplicates_found = {VALIDATE_KEY__MODEL: model_class.__name__}
# ПОДГОТОВКА: Получаем базовый queryset (все записи кроме текущей, если редактируем)
# Это ленивый запрос - запрос выполнится, только когда мы применим фильтры
records_to_check = model_class.objects.all()
if instance_pk is not None:
# При редактировании исключаем текущую запись из поиска, чтобы не найти "самого себя"
records_to_check = records_to_check.exclude(pk=instance_pk)
# ПРОВЕРКА 1: EXACT MATCH (точное совпадение основного поля)
# Ищем: есть ли другая запись с точно таким же main_field_value?
filter_kwargs = {f"{main_field_name}__exact": normalized_main_value}
exact_matches = records_to_check.filter(**filter_kwargs)
if exact_matches.exists():
duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.IS_DUPLICATE,
VALIDATE_KEY__VALUE: exact_matches,
})
return duplicates_found
# ПРОВЕРКА 2: SYNONYM MATCH (совпадение с синонимами в метаданных других записей)
# Ищем: есть ли текущее значение main_field в синонимах других записей?
# Например, если у записи A синонимы=['Sony Music', 'SME Records'],
# а мы добавляем запись B с основным полем 'Sony Music', это совпадение!
# Используем RawSQL для работы с JSON функциями SQLite
# json_each(j_label_metadata, '$.SYNONYM') развертывает массив синонимов в отдельные строки
# Это необходимо т.к. Django ORM для SQLite не поддерживает __contains для JSON полей
from django.db.models.expressions import RawSQL
# Строим RawSQL запрос для поиска в JSON массиве синонимов
# json_each распарсивает массив и ищет совпадение со значением
synonym_matches = records_to_check.annotate(
has_synonym=RawSQL(
f"""
EXISTS (
SELECT 1 FROM json_each({metadata_field_name}, '$.{KEY_SYNONYM}')
WHERE json_each.value = %s
)
""",
(normalized_main_value,)
)
).filter(has_synonym=True)
# Если найдены совпадения в синонимах - возвращаем все найденные записи
if synonym_matches.exists():
duplicates_found.update({
VALIDATE_KEY__MATCH_TYPE: ValidateMatchType.FIND_IN_SYNONYM,
VALIDATE_KEY__VALUE: synonym_matches,
})
return duplicates_found
# Когда все проверки прошли -- возвращаем пустой словарь
return duplicates_found
def validate_entity_for_admin_form(form_instance, cleaned_data,
main_field_name='s_label',
metadata_field_name='j_label_metadata',
request=None):
"""
Универсальный валидатор для админских форм.
Проверяет сущность на совпадения (дубликаты) с уже существующими записями.
Выбрасывает ValidationError с кликабельными ссылками на найденные дубликаты.
Используется во всех админских forms: LabelAdminForm, ArtistAdminForm, MusicStyleAdminForm и т.д.
Args:
form_instance: Экземпляр формы (self из clean методе)
cleaned_data: Очищенные данные формы
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name')
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata')
request: HTTP request объект (опционально, используется для проверки GET параметра ignore_validate)
Raises:
ValidationError: Если найдены совпадения (дубликаты) и GET параметр не установлен
Пример использования в LabelAdminForm:
def clean(self):
cleaned_data = super().clean()
validate_entity_for_admin_form(
self,
cleaned_data,
main_field_name='s_label',
metadata_field_name='j_label_metadata',
request=self.request if hasattr(self, 'request') else None,
)
return cleaned_data
"""
from django.utils.html import mark_safe
# ПЕРЕД ВАЛИДАЦИЕЙ: проверяем, нажата ли submit-кнопка с измененным value='ignore_validate'
# Если пользователь нажал нашу кнопку подтверждения, она меняет value админских кнопок на 'ignore_validate'
if request and any(request.POST.get(btn) == 'ignore_validate' for btn in ['_save', '_addanother', '_continue']):
return
# Получаем класс модели из метаинформации формы
model_class = form_instance.Meta.model
# Получаем значения из формы
main_field_value = cleaned_data.get(main_field_name)
metadata_dict = cleaned_data.get(metadata_field_name) or {}
# Если основное поле не заполнено, пропускаем валидацию
if not main_field_value:
return
# Вызываем основной валидатор дубликатов
result = validate_for_duplicates(
model_class=model_class,
instance_pk=form_instance.instance.pk,
main_field_value=main_field_value,
metadata_dict=metadata_dict,
main_field_name=main_field_name,
metadata_field_name=metadata_field_name,
)
# Обрабатываем результаты проверки в зависимости от типа найденного совпадения
if VALIDATE_KEY__MATCH_TYPE in result:
match_type = result[VALIDATE_KEY__MATCH_TYPE]
duplicates_queryset = result[VALIDATE_KEY__VALUE]
# В dup_links формируем ссылки на найденные дубликаты для быстрого перехода в админке
dup_links = []
# Используем match-case для удобной обработки разных типов совпадений
# С Enum вместо магических чисел код становится самодокументируемым
# В будущем легко добавить новые типы: ValidateMatchType.PARTIAL_MATCH = 2 и т.д.
match match_type:
case ValidateMatchType.IS_DUPLICATE:
# ОБРАБОТКА ТОЧНЫХ ДУБЛИКАТОВ
for dup in duplicates_queryset:
# Относительная ссылка зависит от режима админки:
# При создании: /admin/app/model/add/ → ../456/change/
# При редактировании: /admin/app/model/123/change/ → ../..456/change/
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/"
# Получаем значение основного поля из дубликата для вывода в ссылке
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
# Объединяем все найденные дубликаты в один список
dup_list = ", ".join(dup_links)
# Для случая IS_DUPLICATE отключена проверка force_ignore_validate, т.к. это критическая ситуация
# и проверяемом поле часто unique=True на уровне модели.
raise ValidationError(
mark_safe(
f"ОШИБКА: Найдено ПОЛНОЕ совпадение! "
f"Измените название или отредактируйте {dup_list}."
)
)
case ValidateMatchType.FIND_IN_SYNONYM:
# ОБРАБОТКА СОВПАДЕНИЙ В СИНОНИМАХ
for dup in duplicates_queryset:
rel_url = f"../{dup.pk}/change/" if form_instance.instance.pk is None else f"../../{dup.pk}/change/"
dup_value = getattr(dup, main_field_name, '?')
dup_links.append(f"<big><a href='{rel_url}'>#{dup.pk} '{dup_value}'</a></big>")
dup_list = ", ".join(dup_links)
# Кнопка подтверждения создания несмотря на синонимы
# При клике меняет value всех submit-кнопок на 'ignore_validate' и отправляет форму
# Если пользователь потом меняет данные - вотчер вернет оригинальные значения
confirmation_button = '''
<br><br>
<button type="button"
onclick="
// Меняем value у всех submit-кнопок на 'ignore_validate'
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
btn.value = 'ignore_validate';
});
// Отправляем форму через первую найденную submit-кнопку
document.querySelector('input[type=submit]').click();
"
style="padding: 10px 15px; background: #e74c3c; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold;">
Я уверен, создать несмотря на синонимы
</button>
<em style="display: block; margin-top: 8px; color: #666; font-size: 12px;">
Форма будет переотправлена без проверки синонимов
</em>
<script>
// Вотчер: если пользователь меняет данные в форме, отменяем флаг ignore_validate
document.addEventListener('DOMContentLoaded', function() {
// Сохраняем оригинальные значения submit-кнопок
let originalValues = {};
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
originalValues[btn.name] = btn.value;
});
// Отслеживаем изменения всех input/textarea полей в форме
let formInputs = document.querySelectorAll('input[type!=submit], textarea, select');
formInputs.forEach(function(input) {
input.addEventListener('change', function() {
// Если пользователь изменил данные, восстанавливаем оригинальные значения кнопок
document.querySelectorAll('input[type=submit]').forEach(function(btn) {
btn.value = originalValues[btn.name];
});
});
});
});
</script>
'''
raise ValidationError(
mark_safe(
f"ВНИМАНИЕ: Найдено совпадение в синонимах! "
f"Проверьте {dup_list} "
f"или используйте синонимы из найденной записи."
f"{confirmation_button}"
)
)
case _:
# Неизвестный или не обработанный тип совпадения
# В будущем сюда можно добавить логирование неожиданных типов
pass
def validate_and_raise_for_duplicates(
instance,
main_field_name: str,
metadata_field_name: str,
) -> None:
"""
Валидирует экземпляр модели на дубликаты и выбрасывает ValidationError если найдены.
Используется в переопределённых методах save() моделей для проверки дубликатов
перед сохранением. Получает все необходимые данные из экземпляра модели.
УНИВЕРСАЛЬНЫЙ ХЕЛПЕР — работает для любых моделей (TbLabel, TbArtist, TbMusicStyle и т.д.)
Args:
instance: Экземпляр модели (self из save методе). Обязателен!
main_field_name: Имя основного поля модели ('s_label', 's_artist', 's_style_name'). Обязателен!
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata'). Обязателен!
Raises:
AttributeError: Если указанные поля не существуют в модели
ValidationError: Если найдены совпадения (дубликаты)
Пример использования в TbLabel.save():
def save(self, *args, **kwargs):
# Валидируем ДО работы с данными!
validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
Пример использования в TbArtist.save():
def save(self, *args, **kwargs):
validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
"""
# Получаем класс модели из экземпляра
model_class = instance.__class__
# Проверяем, что указанные поля существуют в модели
for field_name in [main_field_name, metadata_field_name]:
if not hasattr(instance, field_name):
raise AttributeError(
f"{model_class.__name__} instance has no attribute '{field_name}'. "
f"Check that main_field_name and metadata_field_name are correct."
)
main_field_value = getattr(instance, main_field_name)
# Вызываем основной валидатор дубликатов
duplicates_result = validate_for_duplicates(
model_class=model_class,
instance_pk=instance.pk, # None для новых записей
main_field_value=main_field_value, # ЗНАЧЕНИЕ основного поля модели
metadata_dict=getattr(instance, metadata_field_name), # ЗНАЧЕНИЕ поля метаданных модели
main_field_name=main_field_name, # ИМЯ основного поля модели
metadata_field_name=metadata_field_name, # ИМЯ поля метаданных модели
)
# Обрабатываем результаты валидации через match-case
match duplicates_result.get(VALIDATE_KEY__MATCH_TYPE):
case ValidateMatchType.IS_DUPLICATE:
# Точный дубликат найден - это критическая ошибка!
model_name = model_class.__name__
dup_pks = [dup.pk for dup in duplicates_result[VALIDATE_KEY__VALUE]]
raise ValidationError(
f"{model_name}.save(): КРИТИЧЕСКАЯ ОШИБКА! Дубликат '{main_field_value}' уже существует. "
f"PK дубликатов: {dup_pks}. Сохранение отменено!"
)
case _:
# Неизвестный тип совпадения или дубликатов нет
# Это нормальная ситуация - логируем только если что-то странное
if VALIDATE_KEY__MATCH_TYPE in duplicates_result:
model_name = model_class.__name__
logger.warning(
f"{model_name}.save(): Неизвестный тип совпадения: "
f"{duplicates_result.get(VALIDATE_KEY__MATCH_TYPE)}"
)
def update_synonyms_in_metadata(
instance,
main_field_name: str,
metadata_field_name: str,
) -> None:
"""
Обновляет список синонимов в метаданных экземпляра модели.
Универсальный хелпер для управления синонимами во всех моделях (TbLabel, TbArtist, TbMusicStyle и т.д.)
Логика:
- При создании новой записи: добавляет текущее значение поля в SYNONYM
- При редактировании: если значение поля изменилось, добавляет ОБА (старое и новое) в SYNONYM
- Очищает дубликаты в списке синонимов, сохраняя порядок
- Использует KEY_SYNONYM из settings как ключ в metadata словаре
Args:
instance: Экземпляр модели (self из save методе). Обязателен!
main_field_name: Имя основного поля ('s_label', 's_artist', 's_style_name'). Обязателен!
metadata_field_name: Имя поля метаданных ('j_label_metadata', 'j_artist_metadata'). Обязателен!
Пример использования в TbLabel.save():
def save(self, *args, **kwargs):
validate_and_raise_for_duplicates(self, 's_label', 'j_label_metadata')
update_synonyms_in_metadata(self, 's_label', 'j_label_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
Пример использования в TbArtist.save():
def save(self, *args, **kwargs):
validate_and_raise_for_duplicates(self, 's_artist', 'j_artist_metadata')
update_synonyms_in_metadata(self, 's_artist', 'j_artist_metadata')
# ... остальная логика save()
super().save(*args, **kwargs)
"""
model_class = instance.__class__
# Проверяем, что указанные поля существуют в модели
for field_name in [main_field_name, metadata_field_name]:
if not hasattr(instance, field_name):
raise AttributeError(
f"{model_class.__name__} instance has no attribute '{field_name}'. "
f"Check that main_field_name and metadata_field_name are correct."
)
# ===== ОПРЕДЕЛЯЕМ, ЭТО СОЗДАНИЕ ИЛИ РЕДАКТИРОВАНИЕ =====
# Получаем текущее значение основного поля
current_field_value = getattr(instance, main_field_name)
# Определяем новая ли это запись или обновление
is_new = instance.pk is None
# Получаем старое значение поля (для редактирования)
old_field_value = None
if not is_new:
try:
old_instance = model_class.objects.get(pk=instance.pk)
old_field_value = getattr(old_instance, main_field_name)
except model_class.DoesNotExist:
# На случай если что-то пошло не так, считаем это новым
is_new = True
# ===== ИНИЦИАЛИЗИРУЕМ МЕТАДАННЫЕ =====
# Инициализируем metadata если оно пусто
metadata_dict = getattr(instance, metadata_field_name)
if not metadata_dict:
metadata_dict = {}
setattr(instance, metadata_field_name, metadata_dict)
# Убеждаемся, что ключ 'SYNONYM' существует и это список
if KEY_SYNONYM not in metadata_dict or not isinstance(metadata_dict[KEY_SYNONYM], list):
metadata_dict[KEY_SYNONYM] = []
# ===== ДОБАВЛЯЕМ СИНОНИМЫ =====
# Добавляем синонимы при создании ИЛИ если значение поля изменилось
if is_new or old_field_value != current_field_value:
# Если поле было обновлено и значение изменилось - добавляем старое значение
if old_field_value and old_field_value not in metadata_dict[KEY_SYNONYM]:
metadata_dict[KEY_SYNONYM].append(old_field_value)
# Добавляем текущее значение если его еще нет в синонимах
if current_field_value not in metadata_dict[KEY_SYNONYM]:
metadata_dict[KEY_SYNONYM].append(current_field_value)
# ===== ОЧИЩАЕМ ДУБЛИКАТЫ =====
# Удаляем дубликаты в списке синонимов, сохраняя порядок
# (может случиться если пользователь вручную редактировал метаданные)
if KEY_SYNONYM in metadata_dict and isinstance(metadata_dict[KEY_SYNONYM], list):
metadata_dict[KEY_SYNONYM] = list(dict.fromkeys(metadata_dict[KEY_SYNONYM]))
# ===== СООБЩАЕМ DJANGO ЧТО ПОЛЕ ИЗМЕНИЛОСЬ =====
# Для JSONField нужно явно сообщить что мы изменили содержимое
# иначе Django может не сохранить изменения
setattr(instance, metadata_field_name, metadata_dict)