diff --git a/configs/nginx/dq-app--external-nginx.conf b/configs/nginx/dq-app--external-nginx.conf index c7a7689..68a96b3 100644 --- a/configs/nginx/dq-app--external-nginx.conf +++ b/configs/nginx/dq-app--external-nginx.conf @@ -76,18 +76,24 @@ server { } # --- СТРАНИЦЫ ОШИБОК (Custom Error Pages) --- - # Если Django упал (502) или файл из media не найден Nginx-ом (404), показываем наши красивые заглушки. - # Файлы копируются в media/errors при старте контейнера. - # ТРЕБУЕТСЯ ЗАМЕНА ПРИ ДЕПЛОЕ: /home/user/app/dq-site -> ваш реальный путь - error_page 404 /404.html; - error_page 500 502 503 504 /500.html; + # Если Django упал (502) или сработал тайм-аут (504), Nginx должен отдать статический HTML. + # Эти файлы должны лежать в папке, доступной Nginx (например, в media/errors). + # + # ВАЖНО: + # 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; internal; } - location = /500.html { + location = /404.html { root /home/user/app/dq-site/media/errors; internal; } diff --git a/dicquo/dicquo/settings.py b/dicquo/dicquo/settings.py index 28e334f..4d234c9 100644 --- a/dicquo/dicquo/settings.py +++ b/dicquo/dicquo/settings.py @@ -58,6 +58,7 @@ INSTALLED_APPS: list[str] = [ 'django.contrib.sites', 'django.contrib.sitemaps', 'taggit.apps.TaggitAppConfig', + 'django_select2', 'web.apps.WebConfig', ] @@ -78,6 +79,11 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR.parent / 'database/db.sqlite3', + 'OPTIONS': { + # Таймаут ожидания блокировки SQLite (в секундах) + # При сложных операциях (например, каскадное удаление тегов) нужно больше времени + 'timeout': 20, + }, } } diff --git a/dicquo/dicquo/urls.py b/dicquo/dicquo/urls.py index 3ab87c0..762fd92 100644 --- a/dicquo/dicquo/urls.py +++ b/dicquo/dicquo/urls.py @@ -15,7 +15,7 @@ Including another URLconf 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ 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.contrib.sitemaps.views import sitemap from django.views.generic import TemplateView @@ -33,6 +33,7 @@ urlpatterns = [ re_path(r'^$', views.IndexView.as_view()), re_path(r'^(?P\d{1,12})_\S*$', views.DictumDetailView.as_view()), path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'), + path("select2/", include("django_select2.urls")), ] if settings.DEBUG: diff --git a/dicquo/web/admin.py b/dicquo/web/admin.py index 28d3ac6..edf32c3 100644 --- a/dicquo/web/admin.py +++ b/dicquo/web/admin.py @@ -2,6 +2,11 @@ from django.contrib import admin from django import forms 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: from etpgrf.typograph import Typographer @@ -18,6 +23,96 @@ except ImportError: 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): # Виртуальные поля для настройки типографа etp_language = forms.ChoiceField( @@ -62,6 +157,9 @@ class DictumAdminForm(forms.ModelForm): class Meta: model = TbDictumAndQuotes fields = '__all__' + widgets = { + 'tags': TagSelect2Widget, + } # Register your models here. @@ -100,6 +198,10 @@ class AdmDictumAndQuotesAdmin(admin.ModelAdmin): ) readonly_fields = ('szIntroHTML', 'szContentHTML', 'iViewCounter') + formfield_overrides = { + models.ManyToManyField: {'widget': Select2TagWidget}, + } + def save_model(self, request, obj, form, change): # 1. Читаем базовые настройки langs = form.cleaned_data.get('etp_language', 'ru').split(',') @@ -199,6 +301,11 @@ class AdmImages(admin.ModelAdmin): list_display_links = ('id', 'szCaption') empty_value_display = u"-empty-" + # Добавляем виджет для тегов + formfield_overrides = { + TaggableManager: {'widget': TagSelect2Widget}, + } + def get_queryset(self, request): return super().get_queryset(request).prefetch_related('tags') @@ -212,6 +319,11 @@ class AdmAuthor(admin.ModelAdmin): list_display_links = ('id', 'szAuthor') empty_value_display = u"-empty-" + # Добавляем виджет для тегов + formfield_overrides = { + TaggableManager: {'widget': TagSelect2Widget}, + } + def get_queryset(self, request): 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(TbImages, AdmImages) admin.site.register(TbAuthor, AdmAuthor) - diff --git a/dicquo/web/models.py b/dicquo/web/models.py index 8d9104f..1bc0280 100644 --- a/dicquo/web/models.py +++ b/dicquo/web/models.py @@ -16,6 +16,7 @@ import pytils class RuTag(Tag): class Meta: proxy = True + # ordering = ['id'] def slugify(self, tag, i=None): return pytils.translit.slugify(self.name.lower())[:128] @@ -24,6 +25,7 @@ class RuTag(Tag): class RuTaggedItem(TaggedItem): class Meta: proxy = True + # ordering = ['id'] @classmethod def tag_model(cls): @@ -108,7 +110,73 @@ class TbImages(models.Model): # заменим имя файла картинки 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) class Meta: diff --git a/poetry.lock b/poetry.lock index 1bb84c0..211a751 100644 --- a/poetry.lock +++ b/poetry.lock @@ -67,6 +67,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=23.1.0)"] 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]] name = "django-environ" 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"] 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]] name = "django-taggit" version = "6.1.0" @@ -646,4 +675,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "3d7a7f2fe8ec78993616e707e29e96503f134bd1cec48cac7f6dd47814863f4f" +content-hash = "b5fca935982220439294d6b37caaf1d893492df96d65abd6389dfd3c9464b992" diff --git a/public/static/css/select2_taggit_admin.css b/public/static/css/select2_taggit_admin.css new file mode 100644 index 0000000..4c8c956 --- /dev/null +++ b/public/static/css/select2_taggit_admin.css @@ -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; +} + diff --git a/pyproject.toml b/pyproject.toml index ae915f3..2683677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ django-environ = "^0.12.1" whitenoise = "^6.11.0" gunicorn = "^25.1.0" tqdm = "^4.67.3" +django-select2 = "^8.4.8" [build-system]