mod: select2 admin and settings constants

This commit is contained in:
2026-04-10 11:58:31 +03:00
parent 18e32a2a0f
commit dc84178eba
10 changed files with 381 additions and 18 deletions

View File

@@ -1,9 +1,10 @@
"""Настройки Django для проекта cadpoint."""
import os
from pathlib import Path
from django.db.backends.signals import connection_created
from django.dispatch import receiver
import environ
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -57,6 +58,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
# Панель отладки показываем только в dev-окружении при `DEBUG=True`.
'debug_toolbar',
'django_select2',
'easy_thumbnails',
'filer.apps.FilerConfig',
'mptt.apps.MpttConfig',
@@ -157,6 +159,21 @@ CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=[])
# Внутренние адреса для debug toolbar: локальный браузер и loopback.
INTERNAL_IPS = env.list('DJANGO_INTERNAL_IPS', default=['127.0.0.1', '::1'])
# Параметры Select2 в админке.
# Держим их здесь, чтобы не размазывать магические числа по `admin.py`.
SELECT2_AJAX_DELAY_MS = 250
SELECT2_MINIMUM_INPUT_LENGTH = 0
SELECT2_TOKEN_SEPARATORS = '[","]'
SELECT2_PAGE_SIZE = 25
# Параметры SQLite, чтобы дев-окружение не падало на `database is locked`.
# WAL и busy_timeout уменьшают конфликты при чтении/записи, а synchronous=NORMAL
# делает SQLite чуть менее параноидальной, но более живой для локальной разработки.
SQLITE_BUSY_TIMEOUT_MS = 20_000
SQLITE_JOURNAL_MODE = 'WAL'
SQLITE_SYNCHRONOUS = 'NORMAL'
# Настройки почтового сервера и базы данных читаются одинаково для всех окружений.
EMAIL_HOST = env('DJANGO_EMAIL_HOST', default='smtp.mail.ru') # SMTP server
EMAIL_PORT = env.int('DJANGO_EMAIL_PORT', default=2525) # для SSL/https
@@ -167,14 +184,51 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR.parent.joinpath('database', env('DJANGO_SQLITE_NAME', default='cadpoint-db.sqlite3')),
'OPTIONS': {
'timeout': 20,
},
}
}
@receiver(connection_created)
def _configure_sqlite_connection(sender, connection, **kwargs):
"""
Настраиваем SQLite сразу после открытия соединения.
Это нужно, чтобы:
- уменьшить число ошибок `database is locked`;
- позволить чтению и записи меньше мешать друг другу;
- сделать dev-среду более терпимой к админке и Select2-поиску.
"""
if connection.vendor != 'sqlite':
return
with connection.cursor() as cursor:
cursor.execute(f'PRAGMA journal_mode={SQLITE_JOURNAL_MODE};')
cursor.execute(f'PRAGMA synchronous={SQLITE_SYNCHRONOUS};')
cursor.execute(f'PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS};')
SERVER_EMAIL = DEFAULT_FROM_EMAIL = EMAIL_FROM
EMAIL_USE_TLS = True
EMAIL_SUBJECT_PREFIX = '[CADPOINT.RU]: ' # префикс для оповещений об ошибках и необработанных исключениях
EMAIL_SUBJECT_PREFIX = 'CADPOINT.RU => ' # префикс для оповещений об ошибках и необработанных исключениях
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ============================
# ПЕРЕМЕННЫЕ НАСТРОЙКИ ПРОЕКТА
# Число тегов в облаке на главной странице. Выбирается эмпирически, чтобы не
# перегружать интерфейс и не провоцировать лишние запросы к SQLite при
# открытии страницы.
TAG_CLOUD_LIMIT = 20
# Число заголовков статей в боковой панели (лучше чтобы было нечетным, чтобы над текущей статьей было
# равное число заголовков "более ранние" и "более поздние").
NUM_NAV_ITEMS_IN_PAGE = 7
# Число статей (заголовок + тизер) на странице
NUM_ITEMS_IN_PAGE = NUM_NAV_ITEMS_IN_PAGE

View File

@@ -20,6 +20,11 @@ from cadpoint import settings
from web import views
urlpatterns = [
path(
settings.ADMIN_URL + 'tags/autocomplete/',
admin.site.admin_view(views.tag_autocomplete),
name='web_tag_autocomplete',
),
path(settings.ADMIN_URL, admin.site.urls),
re_path(r'^$', views.index),
re_path(r'^p(?P<ppage>\d*)$', views.index),

View File

@@ -1,13 +1,98 @@
# -*- coding: utf-8 -*-
from django import forms
from django.contrib import admin
from django.db import models
from django.forms import TextInput, Textarea
from django.urls import reverse
from django_select2.forms import Select2TagWidget
from web.models import TbContent
from web.add_function import safe_html_special_symbols
from cadpoint import settings
class AjaxCommaSeparatedSelect2TagWidget(Select2TagWidget):
"""
Select2-виджет для `taggit`.
Select2 в браузере работает с массивом значений, а `taggit` ждёт строку
с тегами через запятую. Поэтому здесь есть конвертация туда и обратно.
"""
def value_from_datadict(self, data, files, name):
# Select2 присылает список значений, а `taggit` ожидает строку вида
# "tag-one,tag two,tag-three".
values = super().value_from_datadict(data, files, name)
if isinstance(values, (list, tuple)):
return ",".join(values)
return values
def optgroups(self, name, value, attrs=None):
# При редактировании объекта нужно показать уже выбранные теги.
# При этом не тащим ВСЕ теги из базы — только те, что уже сохранены.
if isinstance(value, (list, tuple)):
raw_values = []
for item in value:
if not item:
continue
raw_values.extend(str(item).split(","))
else:
raw_values = str(value or "").split(",")
values = [item for item in raw_values if item]
selected = set(values)
subgroup = [
self.create_option(name, v, v, v in selected, i)
for i, v in enumerate(values)
]
return [(None, subgroup, 0)]
class AdminContentForm(forms.ModelForm):
class Meta:
model = TbContent
fields = '__all__'
class Media:
css = {
'all': ('css/admin-select2-theme.css',),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# AJAX-виджет подгружает список тегов лениво, а здесь мы оставляем
# только уже выбранные значения, чтобы не тащить все теги из базы при
# открытии формы и не провоцировать лишние запросы к SQLite.
if self.is_bound:
if hasattr(self.data, 'getlist'):
tag_values = self.data.getlist('tags')
else:
raw_values = self.data.get('tags', [])
tag_values = raw_values if isinstance(raw_values, list) else [raw_values]
tag_choices = [(value, value) for value in tag_values if value]
elif self.instance.pk:
tag_choices = [
(name, name)
for name in self.instance.tags.order_by('name').values_list('name', flat=True)
]
else:
tag_choices = []
self.fields['tags'].widget = AjaxCommaSeparatedSelect2TagWidget(
attrs={
'data-ajax--url': reverse('web_tag_autocomplete'),
'data-ajax--cache': 'true',
'data-ajax--data-type': 'json',
'data-ajax--delay': settings.SELECT2_AJAX_DELAY_MS,
'data-token-separators': settings.SELECT2_TOKEN_SEPARATORS,
'data-minimum-input-length': settings.SELECT2_MINIMUM_INPUT_LENGTH,
},
choices=tag_choices,
)
# Register your models here.
class AdminContent(admin.ModelAdmin):
form = AdminContentForm
search_fields = ['szContentHead', 'szContentIntro', 'szContentBody',
'szContentKeywords', 'szContentDescription']
list_display = ('id', 'ContentHeadSafe', 'tag_list', 'bContentPublish', 'tdContentPublishUp')
@@ -49,7 +134,10 @@ class AdminContent(admin.ModelAdmin):
return safe_html_special_symbols(obj.szContentHead)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags')
queryset = super().get_queryset(request)
if request.resolver_match and request.resolver_match.url_name == 'web_tbcontent_changelist':
return queryset.prefetch_related('tags')
return queryset
def tag_list(self, obj):
return u", ".join(o.name for o in obj.tags.all())

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.2.13 on 2026-04-09 12:21
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('taggit', '0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx'),
('web', '0001_squashed_0002_alter_tbcontent_szcontentbody_and_more'),
]
operations = [
migrations.AlterField(
model_name='tbcontent',
name='tags',
field=taggit.managers.TaggableManager(blank=True, help_text='Теги можно выбирать из списка или вводить вручную. Многословные теги поддерживаются без кавычек. <b>Теги нужны для присвоения категорий объектам контента<b>.', through='web.RuTaggedItem', to='taggit.Tag', verbose_name='Теги'),
),
]

View File

@@ -73,8 +73,8 @@ class TbContent(models.Model):
blank=True,
through=RuTaggedItem, # uTaggedItem,
verbose_name=u"Теги",
help_text=u"Теги через запятую… Регистр не чувствителен… Длинные теги, содержащие пробел, заключайте"
u"'в кавычки' <b>Теги нужны для присвоения категорий объектам контента<b>."
help_text=u"Теги можно выбирать из списка или вводить вручную. Многословные теги поддерживаются"
u" без кавычек. <b>Теги нужны для присвоения категорий объектам контента<b>."
)
szContentHead = models.CharField(
max_length=512, default=u"", blank=False, null=False,

View File

@@ -1,4 +1,7 @@
from django.test import SimpleTestCase
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from taggit.models import Tag
from web.legacy_links import build_canonical_url, replace_legacy_links
@@ -31,3 +34,54 @@ class LegacyLinksTests(SimpleTestCase):
self.assertIn('/images/stories/news/photo123.jpg', new_text)
self.assertEqual(len(matches), 1)
class TagAutocompleteTests(TestCase):
def setUp(self):
user_model = get_user_model()
self.user = user_model.objects.create_superuser(
username='admin',
email='admin@example.com',
password='password',
)
Tag.objects.create(name='alpha')
Tag.objects.create(name='beta')
Tag.objects.create(name='gamma')
self.client.force_login(self.user)
def test_returns_tag_results_for_term(self):
response = self.client.get(
reverse('web_tag_autocomplete'),
{'term': 'al'},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload['pagination']['more'], False)
self.assertEqual([item['text'] for item in payload['results']], ['alpha'])
def test_returns_initial_tag_batch_without_term(self):
response = self.client.get(reverse('web_tag_autocomplete'))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload['results']), 3)
self.assertEqual(payload['pagination']['more'], False)
def test_paginates_tag_results(self):
Tag.objects.all().delete()
for index in range(30):
Tag.objects.create(name=f'tag-{index:02d}')
response = self.client.get(reverse('web_tag_autocomplete'), {'page': 1})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload['results']), 25)
self.assertEqual(payload['pagination']['more'], True)
response = self.client.get(reverse('web_tag_autocomplete'), {'page': 2})
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(len(payload['results']), 5)
self.assertEqual(payload['pagination']['more'], False)

View File

@@ -2,14 +2,16 @@
import math
from django.shortcuts import render, HttpResponseRedirect
from django.http import Http404
from django.http import Http404, JsonResponse
from django.db.models import Count, Q
from django.views.decorators.http import require_GET
# from datetime import datetime
from django.utils import timezone
from taggit.models import Tag
from web.models import TbContent
from web.add_function import *
from cadpoint import settings
# Create your views here.
def handler404(request, exception: str):
@@ -35,6 +37,29 @@ def handler500(request):
return response
@require_GET
def tag_autocomplete(request):
"""Отдаёт теги для Select2 лениво, чтобы не грузить всю таблицу сразу."""
term = request.GET.get("term", "").strip()
page = max(int(request.GET.get("page", 1)), 1)
page_size = settings.SELECT2_PAGE_SIZE
queryset = Tag.objects.order_by("name")
if term:
queryset = queryset.filter(name__icontains=term)
start = (page - 1) * page_size
stop = start + page_size + 1
names = list(queryset.values_list("name", flat=True)[start:stop])
more = len(names) > page_size
results = [
{"id": name, "text": name}
for name in names[:page_size]
]
return JsonResponse({"results": results, "pagination": {"more": more}})
def index(request,
slug_tags: str = "",
ppage: int = 0):
@@ -71,9 +96,10 @@ def index(request,
q_content = content_qs.order_by("-tdContentPublishUp")
total_items = q_content.count()
total_page = max(math.ceil(total_items / 7) - 1, 0) if total_items else 0
total_page = max(math.ceil(total_items / settings.NUM_ITEMS_IN_PAGE) - 1, 0) if total_items else 0
q_content = q_content[page_number * 7: page_number * 7 + 7]
q_content = q_content[page_number * settings.NUM_ITEMS_IN_PAGE:
page_number * settings.NUM_ITEMS_IN_PAGE+ settings.NUM_ITEMS_IN_PAGE]
# Готовим облако тегов: общее число публикаций по каждому тегу и число публикаций на текущей странице.
page_ids = list(q_content.values_list("id", flat=True))
@@ -87,7 +113,7 @@ def index(request,
),
)
.filter(NumTotal__gt=0)
.order_by("-NumInPage", "-NumTotal", "name")[:20]
.order_by("-NumInPage", "-NumTotal", "name")[:settings.TAG_CLOUD_LIMIT]
)
to_template["LENTA"] = q_content
@@ -136,12 +162,12 @@ def show_item(request,
Q(tdContentPublishDown__isnull=True) | Q(tdContentPublishDown__gt=timezone.now()),
Q(bContentPublish=True),
Q(tdContentPublishUp__lte=q_item.tdContentPublishUp)
).order_by("-tdContentPublishUp", "id")[:4]
).order_by("-tdContentPublishUp", "id")[:settings.NUM_NAV_ITEMS_IN_PAGE / 2]
q_items_before = TbContent.objects.filter(
Q(tdContentPublishDown__isnull=True) | Q(tdContentPublishDown__gt=timezone.now()),
bContentPublish=True,
tdContentPublishUp__gt=q_item.tdContentPublishUp
).order_by("tdContentPublishUp", "id")[:3]
).order_by("tdContentPublishUp", "id")[:settings.NUM_NAV_ITEMS_IN_PAGE / 2 - 1]
try:
p = 0 if "p" not in request.GET else int(request.GET["p"])
n = 0 if "n" not in request.GET else int(request.GET["n"])
@@ -150,7 +176,7 @@ def show_item(request,
count += 1
if n-count < 1:
i.pp = p - 1
i.nn = n + 7 - count
i.nn = n + settings.NUM_NAV_ITEMS_IN_PAGE - count
else:
i.pp = p
i.nn = n - count
@@ -158,13 +184,13 @@ def show_item(request,
for i in q_items_after:
if i.id != q_item.id:
count += 1
if n+count <= 7:
if n+count <= settings.NUM_NAV_ITEMS_IN_PAGE:
i.pp = p
i.nn = n + count
else:
i.pp = p + 1
i.nn = n+count - 7
to_template["PER_PAGE"] = 7
i.nn = n+count - settings.NUM_NAV_ITEMS_IN_PAGE
to_template["PER_PAGE"] = settings.NUM_NAV_ITEMS_IN_PAGE
to_template["PAGE"] = p
except ValueError:
to_template["PAGE"] = 0

31
poetry.lock generated
View File

@@ -191,6 +191,20 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[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-debug-toolbar"
version = "6.3.0"
@@ -291,6 +305,21 @@ files = [
django = ">=4.2"
typing-extensions = ">=4.12.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"
@@ -743,4 +772,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = ">=3.12,<3.13"
content-hash = "c8a9496d279767e1ce179c985beb1f1813933c600163f061866c70fa413467d0"
content-hash = "8115895b95f66275813106c93c64640813e5939a984e76ad53a17032f0788de0"

View File

@@ -0,0 +1,86 @@
/* Стили Select2 для админки Django 5.
Используем CSS-переменные админки, чтобы оформление автоматически
подстраивалось под светлую, тёмную и auto-тему. */
.select2-container--default .select2-selection--single,
.select2-container--default .select2-selection--multiple {
min-height: 2.5rem;
border: 1px solid var(--border-color) !important;
border-radius: 4px;
background-color: var(--body-bg) !important;
color: var(--body-fg) !important;
}
.select2-container--default .select2-selection--single .select2-selection__rendered,
.select2-container--default .select2-selection--multiple .select2-selection__rendered {
color: var(--body-fg) !important;
}
.select2-container--default .select2-selection--single .select2-selection__placeholder,
.select2-container--default .select2-selection--multiple .select2-selection__placeholder {
color: var(--body-quiet-color) !important;
}
.select2-container--default .select2-selection--single .select2-selection__arrow {
height: 2.4rem;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice {
border: 1px solid var(--border-color) !important;
border-radius: 999px;
background-color: var(--selected-bg) !important;
color: var(--body-fg) !important;
padding: 0.15rem 0.55rem;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: var(--body-quiet-color) !important;
margin-right: 0.35rem;
}
.select2-container--default .select2-search--inline .select2-search__field,
.select2-container--default .select2-search--dropdown .select2-search__field {
color: var(--body-fg) !important;
border: 1px solid var(--border-color) !important;
background-color: var(--body-bg) !important;
}
.select2-container--default .select2-results > .select2-results__options {
background-color: var(--body-bg) !important;
color: var(--body-fg) !important;
}
.select2-container--default .select2-results__option {
color: var(--body-fg) !important;
}
.select2-container--default .select2-results__option--highlighted[aria-selected] {
background-color: var(--primary) !important;
color: var(--primary-fg) !important;
}
.select2-container--default .select2-results__option[aria-selected="true"] {
background-color: var(--selected-bg) !important;
color: var(--body-fg) !important;
}
.select2-container--default.select2-container--focus .select2-selection--multiple,
.select2-container--default.select2-container--open .select2-selection--single,
.select2-container--default.select2-container--open .select2-selection--multiple {
border-color: var(--primary) !important;
}
.select2-dropdown {
border: 1px solid var(--border-color) !important;
background-color: var(--body-bg) !important;
color: var(--body-fg) !important;
}
.select2-container--default .select2-results__option--disabled {
color: var(--body-quiet-color) !important;
}
.select2-container--default .select2-results__group {
color: var(--body-quiet-color) !important;
}

View File

@@ -22,6 +22,7 @@ setuptools = "^82.0"
django-environ = "^0.13"
django-mptt = "^0.18.0"
pytils = "^0.4.4"
django-select2 = "^8.4.8"
[tool.poetry.group.dev.dependencies]
django-debug-toolbar = "^6.3"