add: Добавлен select2 для управления тегами
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m20s

This commit is contained in:
2026-02-25 21:10:11 +03:00
parent f4cce3d08a
commit c3c81d7ff5
8 changed files with 293 additions and 11 deletions

View File

@@ -76,18 +76,24 @@ server {
} }
# --- СТРАНИЦЫ ОШИБОК (Custom Error Pages) --- # --- СТРАНИЦЫ ОШИБОК (Custom Error Pages) ---
# Если Django упал (502) или файл из media не найден Nginx-ом (404), показываем наши красивые заглушки. # Если Django упал (502) или сработал тайм-аут (504), Nginx должен отдать статический HTML.
# Файлы копируются в media/errors при старте контейнера. # Эти файлы должны лежать в папке, доступной Nginx (например, в media/errors).
# ТРЕБУЕТСЯ ЗАМЕНА ПРИ ДЕПЛОЕ: /home/user/app/dq-site -> ваш реальный путь #
error_page 404 /404.html; # ВАЖНО:
error_page 500 502 503 504 /500.html; # 1. Файлы 50x.html (500, 502, 503, 504) копируются в media/errors при старте контейнера (см. docker-compose.prod.yml -> command).
# 2. error_page директива перехватывает ошибки от апстрима (Gunicorn).
location = /404.html { error_page 500 502 503 504 /500.html;
# (Опционально) 404 тоже можно кастомизировать, но обычно Django сам отдает 404.
# Nginx отдаст эту страницу только если сам не найдет статику.
error_page 404 /404.html;
location = /500.html {
root /home/user/app/dq-site/media/errors; root /home/user/app/dq-site/media/errors;
internal; internal;
} }
location = /500.html { location = /404.html {
root /home/user/app/dq-site/media/errors; root /home/user/app/dq-site/media/errors;
internal; internal;
} }

View File

@@ -58,6 +58,7 @@ INSTALLED_APPS: list[str] = [
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.sitemaps', 'django.contrib.sitemaps',
'taggit.apps.TaggitAppConfig', 'taggit.apps.TaggitAppConfig',
'django_select2',
'web.apps.WebConfig', 'web.apps.WebConfig',
] ]
@@ -78,6 +79,11 @@ DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR.parent / 'database/db.sqlite3', 'NAME': BASE_DIR.parent / 'database/db.sqlite3',
'OPTIONS': {
# Таймаут ожидания блокировки SQLite (в секундах)
# При сложных операциях (например, каскадное удаление тегов) нужно больше времени
'timeout': 20,
},
} }
} }

View File

@@ -15,7 +15,7 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, re_path from django.urls import path, re_path, include
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib.sitemaps.views import sitemap from django.contrib.sitemaps.views import sitemap
from django.views.generic import TemplateView from django.views.generic import TemplateView
@@ -33,6 +33,7 @@ urlpatterns = [
re_path(r'^$', views.IndexView.as_view()), re_path(r'^$', views.IndexView.as_view()),
re_path(r'^(?P<dq_id>\d{1,12})_\S*$', views.DictumDetailView.as_view()), re_path(r'^(?P<dq_id>\d{1,12})_\S*$', views.DictumDetailView.as_view()),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
path("select2/", include("django_select2.urls")),
] ]
if settings.DEBUG: if settings.DEBUG:

View File

@@ -2,6 +2,11 @@
from django.contrib import admin from django.contrib import admin
from django import forms from django import forms
from web.models import TbDictumAndQuotes, TbAuthor, TbImages, TbOrigin from web.models import TbDictumAndQuotes, TbAuthor, TbImages, TbOrigin
from taggit.managers import TaggableManager
from django_select2.forms import Select2TagWidget
from taggit.models import Tag
from taggit.utils import parse_tags
from django.db import models
try: try:
from etpgrf.typograph import Typographer from etpgrf.typograph import Typographer
@@ -18,6 +23,96 @@ except ImportError:
def __init__(self, **kwargs): pass def __init__(self, **kwargs): pass
class TagSelect2Widget(Select2TagWidget):
"""
Select2-виджет для django-taggit, работающий по ИМЕНАМ тегов.
- подхватывает уже сохранённые теги;
- показывает выпадающий список из существующих тегов;
- даёт создавать новые теги с пробелами в названии.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# choices: список всех существующих тегов по имени
self.choices = [(t.name, t.name) for t in Tag.objects.all()]
class Media:
css = {
"all": ("css/select2_taggit_admin.css",),
}
def build_attrs(self, base_attrs, extra_attrs=None):
"""
Настраиваем Select2 так, чтобы пробел НЕ разделял тег
на несколько частей (нужны теги с пробелами: «Сергей Курёхин»).
Оставляем в разделителях только запятую.
"""
attrs = super().build_attrs(base_attrs, extra_attrs)
# По умолчанию django-select2 ставит: [",", " "]
# Нам нужен только разделитель-запятая.
# Строка '[","]' — корректный JSON-массив из одного элемента.
# Важно: сюда нужно класть СТРОКУ с JSON-массивом, а не python-список.
# Иначе в HTML окажется "['[\", \"]', ...]" и Select2 будет вести себя непредсказуемо.
attrs["data-token-separators"] = '[","]'
return attrs
def format_value(self, value):
"""
Преобразуем значение из TaggableManager/TagField
в список ИМЁН тегов, который ожидает Select2TagWidget.
"""
from django.db.models import QuerySet
if value is None:
return []
# QuerySet или список Tag-объектов
if isinstance(value, QuerySet):
return [t.name for t in value]
if isinstance(value, (list, tuple, set)):
names = []
for v in value:
if isinstance(v, Tag):
names.append(v.name)
else:
names.append(str(v))
return names
# Строка вида "tag1, tag2" — разбираем в список имён
if isinstance(value, str):
return parse_tags(value)
return super().format_value(value)
def value_from_datadict(self, data, files, name):
"""
Django-Select2 возвращает список значений (['Сергей Курёхин', 'Другой тег']).
Taggit (TagField) ждёт ОДНУ строку, которую потом парсит в список тегов.
Если отдать список, он превратится в строку `"['Сергей', 'Курёхин']"`,
и распарсится в кривые теги — этого мы избегаем.
"""
values = super().value_from_datadict(data, files, name)
if not values:
return ""
# Для нашего виджета value — это уже список имён тегов
tag_names = [str(v).strip() for v in values if str(v).strip()]
if not tag_names:
return ""
# ОДИН многословный тег: "Сергей Курёхин" -> "Сергей Курёхин,"
# Тогда parse_tags переключится в режим "деление по запятым"
if len(tag_names) == 1:
single = tag_names[0]
if " " in single and "," not in single and '"' not in single:
return single + ","
return single
# Несколько тегов — явная запятая между ними.
return ", ".join(tag_names)
class DictumAdminForm(forms.ModelForm): class DictumAdminForm(forms.ModelForm):
# Виртуальные поля для настройки типографа # Виртуальные поля для настройки типографа
etp_language = forms.ChoiceField( etp_language = forms.ChoiceField(
@@ -62,6 +157,9 @@ class DictumAdminForm(forms.ModelForm):
class Meta: class Meta:
model = TbDictumAndQuotes model = TbDictumAndQuotes
fields = '__all__' fields = '__all__'
widgets = {
'tags': TagSelect2Widget,
}
# Register your models here. # Register your models here.
@@ -100,6 +198,10 @@ class AdmDictumAndQuotesAdmin(admin.ModelAdmin):
) )
readonly_fields = ('szIntroHTML', 'szContentHTML', 'iViewCounter') readonly_fields = ('szIntroHTML', 'szContentHTML', 'iViewCounter')
formfield_overrides = {
models.ManyToManyField: {'widget': Select2TagWidget},
}
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
# 1. Читаем базовые настройки # 1. Читаем базовые настройки
langs = form.cleaned_data.get('etp_language', 'ru').split(',') langs = form.cleaned_data.get('etp_language', 'ru').split(',')
@@ -199,6 +301,11 @@ class AdmImages(admin.ModelAdmin):
list_display_links = ('id', 'szCaption') list_display_links = ('id', 'szCaption')
empty_value_display = u"<b style='color:red;'>-empty-</b>" empty_value_display = u"<b style='color:red;'>-empty-</b>"
# Добавляем виджет для тегов
formfield_overrides = {
TaggableManager: {'widget': TagSelect2Widget},
}
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags') return super().get_queryset(request).prefetch_related('tags')
@@ -212,6 +319,11 @@ class AdmAuthor(admin.ModelAdmin):
list_display_links = ('id', 'szAuthor') list_display_links = ('id', 'szAuthor')
empty_value_display = u"<b style='color:red;'>-empty-</b>" empty_value_display = u"<b style='color:red;'>-empty-</b>"
# Добавляем виджет для тегов
formfield_overrides = {
TaggableManager: {'widget': TagSelect2Widget},
}
def get_queryset(self, request): def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags') return super().get_queryset(request).prefetch_related('tags')
@@ -223,4 +335,3 @@ admin.site.register(TbDictumAndQuotes, AdmDictumAndQuotesAdmin)
admin.site.register(TbOrigin, AdmOrigin) admin.site.register(TbOrigin, AdmOrigin)
admin.site.register(TbImages, AdmImages) admin.site.register(TbImages, AdmImages)
admin.site.register(TbAuthor, AdmAuthor) admin.site.register(TbAuthor, AdmAuthor)

View File

@@ -16,6 +16,7 @@ import pytils
class RuTag(Tag): class RuTag(Tag):
class Meta: class Meta:
proxy = True proxy = True
# ordering = ['id']
def slugify(self, tag, i=None): def slugify(self, tag, i=None):
return pytils.translit.slugify(self.name.lower())[:128] return pytils.translit.slugify(self.name.lower())[:128]
@@ -24,6 +25,7 @@ class RuTag(Tag):
class RuTaggedItem(TaggedItem): class RuTaggedItem(TaggedItem):
class Meta: class Meta:
proxy = True proxy = True
# ordering = ['id']
@classmethod @classmethod
def tag_model(cls): def tag_model(cls):
@@ -108,7 +110,73 @@ class TbImages(models.Model):
# заменим имя файла картинки # заменим имя файла картинки
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.imFile.name = pytils.translit.slugify(self.szCaption.lower()) + str(Path(self.imFile.name).suffixes) import os
from django.conf import settings
old_obj = None
old_file_path = None
# Получаем старую запись, если она есть
if self.pk:
try:
old_obj = TbImages.objects.get(pk=self.pk)
# Пытаемся получить путь к файлу. Если файл не найден физически, Django может выкинуть ошибку здесь или позже
# Поэтому просто берем имя из БД и формируем путь руками, чтобы не зависеть от Storage
if old_obj.imFile:
old_file_path = os.path.join(settings.MEDIA_ROOT, str(old_obj.imFile.name))
except TbImages.DoesNotExist:
pass
# Fix 1: Если старый путь уже битый (содержит ['...'])
if old_file_path and "['" in old_file_path:
# Формируем "исправленный" путь (каким он должен быть)
corrected_path = old_file_path.replace("['", "").replace("']", "").replace("'", "")
# Проверяем: если битого файла нет, а исправленный есть -> значит БД врет
if not os.path.exists(old_file_path) and os.path.exists(corrected_path):
# Исправляем текущее имя файла в объекте (убираем мусор из имени)
self.imFile.name = str(self.imFile.name).replace("['", "").replace("']", "").replace("'", "")
# Обновляем переменную old_file_path, чтобы дальнейшая логика переименования работала корректно
old_file_path = corrected_path
# Получаем текущее имя и расширение (уже возможно исправленное выше)
current_path = Path(str(self.imFile.name))
current_suffix = current_path.suffix
# Fix 2: Чиним расширение еще раз (на всякий случай, если Fix 1 не сработал или это новый объект)
if "['" in str(current_suffix):
current_suffix = str(current_suffix).replace("['", "").replace("']", "").replace("'", "")
# Формируем новое имя файла на основе заголовка (Slug)
new_filename = pytils.translit.slugify(self.szCaption.lower()) + current_suffix
# Определяем папку (если есть родитель, используем его, иначе img2)
# Важно: self.imFile.name может содержать полный путь. Нам нужен только относительный от MEDIA_ROOT
# Но проще взять родителя из текущего имени
parent_dir = current_path.parent.name if current_path.parent.name else 'img2'
new_name_with_path = str(Path(parent_dir) / new_filename)
# Переименование физического файла
# Сравниваем старое имя (из БД) с новым (сгенерированным)
if old_obj and str(old_obj.imFile.name) != new_name_with_path:
new_file_full_path = os.path.join(settings.MEDIA_ROOT, new_name_with_path)
# Если старый файл (old_file_path) существует физически, переименовываем его
if old_file_path and os.path.exists(old_file_path):
try:
os.makedirs(os.path.dirname(new_file_full_path), exist_ok=True)
os.rename(old_file_path, new_file_full_path)
self.imFile.name = new_name_with_path
except OSError as e:
print(f"Error renaming file from {old_file_path} to {new_file_full_path}: {e}")
else:
# Если старого файла нет, просто обновляем имя в БД
self.imFile.name = new_name_with_path
else:
# Если имя не менялось или объекта не было, просто устанавливаем правильное имя
# (например, чтобы убрать мусор из расширения в БД)
self.imFile.name = new_name_with_path
super(TbImages, self).save(*args, **kwargs) super(TbImages, self).save(*args, **kwargs)
class Meta: class Meta:

31
poetry.lock generated
View File

@@ -67,6 +67,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=23.1.0)"] argon2 = ["argon2-cffi (>=23.1.0)"]
bcrypt = ["bcrypt (>=4.1.1)"] bcrypt = ["bcrypt (>=4.1.1)"]
[[package]]
name = "django-appconf"
version = "1.2.0"
description = "A helper class for handling configuration defaults of packaged apps gracefully."
optional = false
python-versions = ">=3.9"
files = [
{file = "django_appconf-1.2.0-py3-none-any.whl", hash = "sha256:b81bce5ef0ceb9d84df48dfb623a32235d941c78cc5e45dbb6947f154ea277f4"},
{file = "django_appconf-1.2.0.tar.gz", hash = "sha256:15a88d60dd942d6059f467412fe4581db632ef03018a3c183fb415d6fc9e5cec"},
]
[package.dependencies]
django = "*"
[[package]] [[package]]
name = "django-environ" name = "django-environ"
version = "0.12.1" version = "0.12.1"
@@ -83,6 +97,21 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2024.8.6)", "pytest (>=4.6.11)",
docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-notfound-page"] docs = ["furo (>=2024.8.6)", "sphinx (>=5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)", "setuptools (>=71.0.0)"]
[[package]]
name = "django-select2"
version = "8.4.8"
description = "This is a Django_ integration of Select2_."
optional = false
python-versions = ">=3.10"
files = [
{file = "django_select2-8.4.8-py3-none-any.whl", hash = "sha256:a2ce6a4c556dd2d4d57eb3753618d6f31f8d3910e9d9fa1b686d9340f50b14eb"},
{file = "django_select2-8.4.8.tar.gz", hash = "sha256:592e52effff2b5850cb7c98b265715b6704fb784699c4aedddfdd8ae1ffa1e81"},
]
[package.dependencies]
django = ">=4.2"
django-appconf = ">=0.6.0"
[[package]] [[package]]
name = "django-taggit" name = "django-taggit"
version = "6.1.0" version = "6.1.0"
@@ -646,4 +675,4 @@ brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "3d7a7f2fe8ec78993616e707e29e96503f134bd1cec48cac7f6dd47814863f4f" content-hash = "b5fca935982220439294d6b37caaf1d893492df96d65abd6389dfd3c9464b992"

View File

@@ -0,0 +1,60 @@
/* Select2 (django-select2) dark theme compatibility for Django Admin.
We intentionally scope to dark mode only and lean on Django Admin CSS variables. */
@media (prefers-color-scheme: dark) {
html:not([data-theme="light"]) .select2-container--default .select2-selection--single,
html:not([data-theme="light"]) .select2-container--default .select2-selection--multiple {
background: var(--body-bg, #1e1e1e) !important;
color: var(--body-fg, #e6e6e6) !important;
border-color: var(--border-color, #3a3a3a) !important;
}
}
html[data-theme="dark"] .select2-container--default .select2-selection--single,
html[data-theme="dark"] .select2-container--default .select2-selection--multiple {
background: var(--body-bg, #1e1e1e) !important;
color: var(--body-fg, #e6e6e6) !important;
border-color: var(--border-color, #3a3a3a) !important;
}
html[data-theme="dark"] .select2-container--default .select2-selection__rendered {
color: var(--body-fg, #e6e6e6) !important;
}
html[data-theme="dark"] .select2-container--default .select2-search--inline .select2-search__field,
html[data-theme="dark"] .select2-container--default .select2-search--dropdown .select2-search__field {
background: transparent !important;
color: var(--body-fg, #e6e6e6) !important;
}
html[data-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice {
background: rgba(255, 255, 255, 0.08) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: var(--body-fg, #e6e6e6) !important;
}
html[data-theme="dark"] .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: var(--body-fg, #e6e6e6) !important;
opacity: 0.8;
}
html[data-theme="dark"] .select2-dropdown {
background: var(--body-bg, #1e1e1e) !important;
color: var(--body-fg, #e6e6e6) !important;
border-color: var(--border-color, #3a3a3a) !important;
}
html[data-theme="dark"] .select2-container--default .select2-results__option {
color: var(--body-fg, #e6e6e6) !important;
}
html[data-theme="dark"] .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
background: rgba(255, 255, 255, 0.10) !important;
color: var(--body-fg, #ffffff) !important;
}
html[data-theme="dark"] .select2-container--default .select2-results__option--selected {
background: rgba(255, 255, 255, 0.06) !important;
color: var(--body-fg, #e6e6e6) !important;
}

View File

@@ -17,6 +17,7 @@ django-environ = "^0.12.1"
whitenoise = "^6.11.0" whitenoise = "^6.11.0"
gunicorn = "^25.1.0" gunicorn = "^25.1.0"
tqdm = "^4.67.3" tqdm = "^4.67.3"
django-select2 = "^8.4.8"
[build-system] [build-system]