diff --git a/cadpoint/cadpoint/settings.py b/cadpoint/cadpoint/settings.py index de8fb55..f1a3406 100644 --- a/cadpoint/cadpoint/settings.py +++ b/cadpoint/cadpoint/settings.py @@ -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 + diff --git a/cadpoint/cadpoint/urls.py b/cadpoint/cadpoint/urls.py index 8f9c64a..d539d22 100644 --- a/cadpoint/cadpoint/urls.py +++ b/cadpoint/cadpoint/urls.py @@ -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\d*)$', views.index), diff --git a/cadpoint/web/admin.py b/cadpoint/web/admin.py index 8864232..12d026e 100644 --- a/cadpoint/web/admin.py +++ b/cadpoint/web/admin.py @@ -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()) diff --git a/cadpoint/web/migrations/0003_alter_tbcontent_tags.py b/cadpoint/web/migrations/0003_alter_tbcontent_tags.py new file mode 100644 index 0000000..9876746 --- /dev/null +++ b/cadpoint/web/migrations/0003_alter_tbcontent_tags.py @@ -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='Теги можно выбирать из списка или вводить вручную. Многословные теги поддерживаются без кавычек. Теги нужны для присвоения категорий объектам контента.', through='web.RuTaggedItem', to='taggit.Tag', verbose_name='Теги'), + ), + ] diff --git a/cadpoint/web/models.py b/cadpoint/web/models.py index bae9a2d..5b53e08 100644 --- a/cadpoint/web/models.py +++ b/cadpoint/web/models.py @@ -73,8 +73,8 @@ class TbContent(models.Model): blank=True, through=RuTaggedItem, # uTaggedItem, verbose_name=u"Теги", - help_text=u"Теги через запятую… Регистр не чувствителен… Длинные теги, содержащие пробел, заключайте" - u"'в кавычки'… Теги нужны для присвоения категорий объектам контента." + help_text=u"Теги можно выбирать из списка или вводить вручную. Многословные теги поддерживаются" + u" без кавычек. Теги нужны для присвоения категорий объектам контента." ) szContentHead = models.CharField( max_length=512, default=u"", blank=False, null=False, diff --git a/cadpoint/web/tests.py b/cadpoint/web/tests.py index 98c8fa1..76afeca 100644 --- a/cadpoint/web/tests.py +++ b/cadpoint/web/tests.py @@ -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) + + diff --git a/cadpoint/web/views.py b/cadpoint/web/views.py index ecb7a0a..d765aa3 100644 --- a/cadpoint/web/views.py +++ b/cadpoint/web/views.py @@ -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 diff --git a/poetry.lock b/poetry.lock index a51b9aa..d15f8fb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/public/static/css/admin-select2-theme.css b/public/static/css/admin-select2-theme.css new file mode 100644 index 0000000..c0d0f44 --- /dev/null +++ b/public/static/css/admin-select2-theme.css @@ -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; +} + diff --git a/pyproject.toml b/pyproject.toml index 4a6b1df..b730f64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"