Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86bfd9b07b | |||
| c3c81d7ff5 | |||
| f4cce3d08a | |||
| 45275c51f6 |
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
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
|
||||||
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from etpgrf.typograph import Typographer
|
from etpgrf.typograph import Typographer
|
||||||
@@ -18,6 +24,101 @@ 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: список всех существующих тегов по имени.
|
||||||
|
# Важно: на этапах вроде collectstatic таблицы taggit ещё может не быть,
|
||||||
|
# поэтому оборачиваем в try/except и молча игнорируем отсутствие БД.
|
||||||
|
try:
|
||||||
|
self.choices = [(t.name, t.name) for t in Tag.objects.all()]
|
||||||
|
except (OperationalError, ProgrammingError):
|
||||||
|
self.choices = []
|
||||||
|
|
||||||
|
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 +163,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 +204,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 +307,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 +325,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 +341,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ services:
|
|||||||
- REPO_PASS=${REPO_PASS}
|
- REPO_PASS=${REPO_PASS}
|
||||||
- WATCHTOWER_SCOPE=dq-scope
|
- WATCHTOWER_SCOPE=dq-scope
|
||||||
- WATCHTOWER_CLEANUP=true # Удалять старые образы после обновления
|
- WATCHTOWER_CLEANUP=true # Удалять старые образы после обновления
|
||||||
- WATCHTOWER_POLL_INTERVAL=1800 # Проверять каждые 30 минут
|
|
||||||
- DOCKER_API_VERSION=1.44
|
- DOCKER_API_VERSION=1.44
|
||||||
|
command: --interval 1800 --cleanup # Проверять каждые 30 минут
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|||||||
31
poetry.lock
generated
31
poetry.lock
generated
@@ -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"
|
||||||
|
|||||||
60
public/static/css/select2_taggit_admin.css
Normal file
60
public/static/css/select2_taggit_admin.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -26,3 +26,17 @@ _tmr.push({id: "3744288", type: "pageView", start: (new Date()).getTime()});
|
|||||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
|
||||||
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=106953063', 'ym');
|
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js?id=106953063', 'ym');
|
||||||
ym(106953063, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
ym(106953063, 'init', {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
|
||||||
|
// Google Analytics (GA4) counter
|
||||||
|
(function() {
|
||||||
|
var gaScript = document.createElement('script');
|
||||||
|
gaScript.async = true;
|
||||||
|
gaScript.src = 'https://www.googletagmanager.com/gtag/js?id=G-WTJM8J9YL5';
|
||||||
|
document.head.appendChild(gaScript);
|
||||||
|
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
// Делаем функцию глобально доступной, если понадобится вызывать её из других скриптов
|
||||||
|
window.gtag = gtag;
|
||||||
|
gtag('js', new Date());
|
||||||
|
gtag('config', 'G-WTJM8J9YL5');
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user