25 Commits

Author SHA1 Message Date
7f96932a40 fix: directory with proper permissions 1000:1000 in Dockerfile
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 45s
2026-03-20 17:31:24 +03:00
d59c867010 fix: increase SQLite timeout to 60s and enable WAL mode for better concurrency with multiple Gunicorn workers
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-03-20 03:05:38 +03:00
9ea851ad95 fix: add /app/database directory creation with app:app ownership in Dockerfile
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-03-20 02:28:58 +03:00
5146f88c7d mod: fix: add sqlite3 utility to final stage Dockerfile for manage.py dbshell support
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m3s
2026-03-20 01:52:03 +03:00
20ecb9cc4c fix: set X-Forwarded-Proto to https explicitly for internal proxying to Docker 2026-03-20 01:09:08 +03:00
ea2d352cae fix: copy /usr/local/bin from builder stage to include gunicorn executable
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-03-20 00:51:53 +03:00
d459eedf41 fix: create media/errors directory with proper permissions in Dockerfile
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-03-20 00:43:42 +03:00
9d31429e14 fix: uncomment chown for nginx_configs_host and correct volume path to ./config
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-03-20 00:29:54 +03:00
c5963d1d30 mod: Dockerfile .
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-03-20 00:19:39 +03:00
12001bc749 mod: Dockerfile
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 22s
2026-03-20 00:17:31 +03:00
99a2ace43f fix: arm64 bild & create nginx config directory with proper permissions before USER app
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m36s
2026-03-19 23:57:07 +03:00
81efaf1ba5 fix: correct volume path in docker-compose.prod.yml and add model migration
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 28s
2026-03-19 23:34:55 +03:00
42b378fcbc fix: correct sed command and paths in docker-compose.prod.yml for proper nginx config generation 2026-03-19 19:33:12 +03:00
ba4175dfdb mod: больше времени на сборку
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m20s
2026-03-19 17:24:35 +03:00
4194b351d2 mod: собираем образы только linux/amd64 2026-03-19 17:22:31 +03:00
be68a82927 fix: dockerfile - add AS keywords and create staticfiles directory (root)
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 3m5s
2026-03-19 17:12:11 +03:00
53b127a966 fix: dockerfile - add AS keywords and create staticfiles directory
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m57s
2026-03-19 17:06:05 +03:00
746c50a988 fix: rebuild with new docker configuration and healthcheck support
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 2m5s
2026-03-19 17:00:00 +03:00
2520362ad5 mod: Новая версия типографа etpgrf и стили для висячей пунктуации
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 2m27s
2026-03-19 15:37:38 +03:00
a857101c3f mod: улучшения для видимости ИИ 2026-03-19 13:39:44 +03:00
7eeb44a1f5 fix: canonical в продакшен не должен показывать http вместо https
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 2m0s
2026-03-05 14:02:59 +03:00
86bfd9b07b fix: "обход" скрытых миграций TagIt для продакшн.
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 1m34s
2026-02-25 21:31:33 +03:00
c3c81d7ff5 add: Добавлен select2 для управления тегами
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m20s
2026-02-25 21:10:11 +03:00
f4cce3d08a mod: Корректная проверка обновлений каждый 30 минут (1800 сек.) 2026-02-23 20:03:06 +03:00
45275c51f6 add: Счетчик Google Analytics (GA4 - поток Goofle Tag) 2026-02-23 19:58:29 +03:00
16 changed files with 473 additions and 84 deletions

View File

@@ -63,3 +63,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# ДОБАВЛЕНО:
cache-from: type=gha
cache-to: type=gha,mode=max
# И это для медленного интернета:
timeout: 900 # 15 минут на всю сборку

View File

@@ -1,63 +1,105 @@
# ==========================================
# Dockerfile для Django + Gunicorn + WhiteNoise
# ==========================================
# =================================================
# STAGE 1: Builder - Установка зависимостей
# =================================================
FROM python:3.12-slim AS builder
# 1. Базовый образ: Python 3.12 (Slim версия для меньшего размера)
FROM python:3.12-slim
# 2. Переменные окружения для Python
# PYTHONDONTWRITEBYTECODE: Запрещает Python писать .pyc файлы
# PYTHONUNBUFFERED: Гарантирует, что вывод консоли (logs) виден сразу (не буферизуется)
# Устанавливаем переменные окружения
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Poetry настройки: не создавать виртуальное окружение внутри контейнера (ставим системно).
# Дублирует `poetry config virtualenvs.create false` в пп.7 (на всякий случай).
# Говорим Poetry, чтобы он не создавал venv, а ставил пакеты в системный site-packages
ENV POETRY_VIRTUALENVS_CREATE=false
# Путь настройки Django (по умолчанию для production) на случай если контейнер будет запущен не через docker-compose.
ENV DJANGO_SETTINGS_MODULE=dicquo.settings
# 3. Рабочая директория внутри контейнера
WORKDIR /app
# 4. Установка системных зависимостей
# - libjpeg-dev zlib1g-dev: библиотеки для работы с изображениями (Pillow)
# Устанавливаем системные зависимости, необходимые для СБОРКИ пакетов (например, Pillow)
# build-essential нужен для компиляции, -dev пакеты для сборки Pillow
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libjpeg-dev \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
# 5. Установка Poetry через pip (быстро и надежно)
# Устанавливаем Poetry
RUN pip install --no-cache-dir poetry
# 6. Копируем файлы зависимостей (pyproject.toml и poetry.lock)
# Делаем это ДО копирования всего кода, чтобы использовать кэш Docker layers.
# Создаем рабочую директорию
WORKDIR /app
# Копируем только файлы зависимостей для кэширования этого слоя
COPY pyproject.toml poetry.lock /app/
# 7. Установка зависимостей проекта
# --no-interaction: не будет спрашивать подтверждения
# --no-ansi: уберваем цветные символы из логов сборки (они иногда мусорят)
# --no-root: не устанавливать сам проект как пакет (мы просто копируем код)
# --only main: не ставить dev-зависимости (тесты, линтеры и т.п.) для продакшена
# RUN poetry install --no-root --only main
# Настройка Poetry: не создавать venv и установка зависимостей (без dev-зависимостей для продакшена)
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root --only main
# Устанавливаем зависимости проекта. Poetry установит их в /usr/local/lib/python3.12/site-packages
RUN poetry install --no-interaction --no-ansi --no-root --only main
# 8. Копируем весь исходный код проекта в контейнер
COPY . /app/
# 9. Сборка статики (CSS, JS)
# Важно: Запускаем collectstatic с фейковым SECRET_KEY, так как на этапе сборки env файла может не быть.
RUN SECRET_KEY=dummy_build_key python dicquo/manage.py collectstatic --noinput --clear
# =================================================
# STAGE 2: Final - Создание чистого и безопасного образа
# =================================================
FROM python:3.12-slim AS stage-final
# 10. Открываем порт 8000
# Устанавливаем переменные окружения
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=dicquo.settings
# Устанавливаем только RUNTIME системные зависимости.
# Пакеты -dev и build-essential здесь не нужны.
RUN apt-get update && apt-get install -y --no-install-recommends \
libjpeg62-turbo \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Создаем пользователя без прав root для безопасности
# RUN addgroup --system app && adduser --system --ingroup app app
# Создаем рабочую директорию
WORKDIR /home/app/web
# Копируем установленные Python-пакеты из builder-стадии
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# Копируем исполняемые файлы (gunicorn, pip и т.д.)
COPY --from=builder /usr/local/bin /usr/local/bin
# Копируем исходный код проекта и устанавливаем правильного владельца
# ИЗМЕНЕНИЕ: app:app -> 1000:1000
COPY --chown=1000:1000 . .
# Создаём директорию для конфигов nginx и даём права пользователю app
# Это выполняется ещё от root, поэтому проблем с permissions не будет.
RUN mkdir -p /nginx_configs_host/nginx && chown -R 1000:1000 /nginx_configs_host
# Создаём директорию для собранной статики и даём права пользователю app
RUN mkdir -p /home/app/web/staticfiles && chown -R 1000:1000 /home/app/web/staticfiles
# Создаём директорию для ошибок (404, 500) и даём права пользователю app
RUN mkdir -p /app/public/media/errors && chown -R 1000:1000 /app/public/media
# Создаём директорию для БД и даём права пользователю app
# Это важно когда БД монтируется как том с хоста
RUN mkdir -p /app/database && chown -R 1000:1000 /app/database
# Переключаемся на пользователя без прав root
USER 1000
# Собираем статику
# Используем dummy ключ, так как .env файла нет на этапе сборки
RUN SECRET_KEY=dummy python dicquo/manage.py collectstatic --noinput --clear
# Открываем порт
EXPOSE 8000
# 11. Команда запуска
# Переходим в подпапку dicquo, где лежит код Django проекта
WORKDIR /app/dicquo
# Проверка здоровья контейнера
# Docker будет периодически проверять, жив ли контейнер, отправляя GET запрос к главной странице.
# Параметры:
# --interval=30s - проверка каждые 30 секунд
# --timeout=3s - ожидаем ответ максимум 3 секунды
# --start-period=10s - даем контейнеру 10 секунд на запуск перед первой проверкой
# --retries=3 - объявляем контейнер unhealthy после 3 неудачных попыток
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/').read()" || exit 1
# Запускаем Gunicorn (по умолчанию, если не переопределено в docker-compose) на три воркера, привязывая его к
# порту 8000 и указывая на точку входа приложения (wsgi.py).
# Переходим в директорию с manage.py для корректного запуска gunicorn
WORKDIR /home/app/web/dicquo
# Команда запуска
CMD ["gunicorn", "--workers", "3", "--bind", "0.0.0.0:8000", "dicquo.wsgi:application"]

View File

@@ -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;
}
@@ -102,7 +108,9 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# ВАЖНО: Явно указываем https, потому что клиент всегда приходит по HTTPS к Nginx
# Даже если внутри контейнера это HTTP на 127.0.0.1:8010, для Django это должно быть HTTPS
proxy_set_header X-Forwarded-Proto https;
# Тайм-ауты (важно для долгих операций, если они есть)
proxy_read_timeout 180s;

View File

@@ -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,13 @@ DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR.parent / 'database/db.sqlite3',
'OPTIONS': {
# Таймаут ожидания блокировки SQLite (в секундах)
# ВАЖНО: Увеличен до 60 сек для работы с несколькими воркерами Gunicorn
'timeout': 60,
# Дополнительные опции для лучшей работы SQLite при concurrent доступе
'init_command': "PRAGMA journal_mode=WAL;", # Write-Ahead Logging для лучшей concurrency
},
}
}
@@ -148,3 +156,12 @@ if not DEBUG:
WHITENOISE_ROOT = BASE_DIR.parent / 'public'
SITE_ID = 1
# Настройки безопасности для работы за прокси
if not DEBUG:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True

View File

@@ -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<dq_id>\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:

View File

@@ -19,6 +19,7 @@
{# Favicons #}<link rel="shortcut icon" type="image/x-icon" href="{% static 'img/favicon.ico' %}"/>
<link rel="icon" type="image/png" href="{% static 'img/favicon.png' %}"/>
{# Technical Meta #}<meta http-equiv="Last-Modified" content="{% block Last4Meta %}{% endblock %}"/>
{# Для ИИ #}<link rel="help" type="text/markdown" href="/llms.txt"/>
{# CSS #}<link rel="stylesheet" href="{% static 'css/dicquo.css' %}"/>
<noscript><style>body { opacity: 1; }</style></noscript>{# Показать все если JS не поддерживатся #}
{% block ExtraHead %}{# Если нужно что=то добавить в `<head>` #}{% endblock %}

View File

@@ -2,6 +2,12 @@
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
from django.db.utils import OperationalError, ProgrammingError
try:
from etpgrf.typograph import Typographer
@@ -18,6 +24,101 @@ except ImportError:
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):
# Виртуальные поля для настройки типографа
etp_language = forms.ChoiceField(
@@ -62,6 +163,9 @@ class DictumAdminForm(forms.ModelForm):
class Meta:
model = TbDictumAndQuotes
fields = '__all__'
widgets = {
'tags': TagSelect2Widget,
}
# Register your models here.
@@ -100,6 +204,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 +307,11 @@ class AdmImages(admin.ModelAdmin):
list_display_links = ('id', 'szCaption')
empty_value_display = u"<b style='color:red;'>-empty-</b>"
# Добавляем виджет для тегов
formfield_overrides = {
TaggableManager: {'widget': TagSelect2Widget},
}
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags')
@@ -212,6 +325,11 @@ class AdmAuthor(admin.ModelAdmin):
list_display_links = ('id', 'szAuthor')
empty_value_display = u"<b style='color:red;'>-empty-</b>"
# Добавляем виджет для тегов
formfield_overrides = {
TaggableManager: {'widget': TagSelect2Widget},
}
def get_queryset(self, request):
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(TbImages, AdmImages)
admin.site.register(TbAuthor, AdmAuthor)

View File

@@ -0,0 +1,17 @@
# Generated by Django 6.0.2 on 2026-03-19 20:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('web', '0003_alter_tbdictumandquotes_btypograph_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='tbdictumandquotes',
options={'ordering': ['-id'], 'verbose_name': 'ВЫСКАЗЫВАНИЕ', 'verbose_name_plural': 'ВЫСКАЗЫВАНИЯ'},
),
]

View File

@@ -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:

View File

@@ -38,14 +38,14 @@ services:
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
mkdir -p /nginx_configs_host/nginx &&
sed \"s|/home/user/app/dq-site|${HOST_PROJECT_PATH:-/home/default_user/projects/dq-site}|g\" /app/configs/nginx/dq-app--external-nginx.conf > /nginx_configs_host/nginx/nginx_dq.conf.example &&
sed \"s|/home/user/app/dq-site|${HOST_PROJECT_PATH:-/home/default_user/projects/dq-site}|g\" /nginx_configs_host/nginx/dq-app--external-nginx.conf > /nginx_configs_host/nginx/nginx_dq.conf.example &&
if [ ! -f /nginx_configs_host/nginx/dq-app--external-nginx.conf ]; then
cp /nginx_configs_host/nginx/nginx_dq.conf.example /nginx_configs_host/nginx/dq-app--external-nginx.conf;
echo 'INIT: Created new nginx config with correct paths';
fi &&
mkdir -p /app/public/media/errors &&
cp /app/dicquo/templates/static_404.html /app/public/media/errors/404.html &&
cp /app/dicquo/templates/static_500.html /app/public/media/errors/500.html &&
cp /home/app/web/dicquo/templates/static_404.html /app/public/media/errors/404.html &&
cp /home/app/web/dicquo/templates/static_500.html /app/public/media/errors/500.html &&
gunicorn --workers 3 --bind 0.0.0.0:8000 dicquo.wsgi:application"
# 4. Проброс портов (Внешний Nginx -> localhost:8010)
@@ -67,7 +67,10 @@ services:
# Это нужно, чтобы скрипт запуска мог положить туда .example конфиг и прочитать боевой конфиг.
- ./config:/nginx_configs_host
# 6. Переменные окружения
# 6. Запускать от имени пользователя с UID 1000 и GID 1000
user: "1000:1000"
# 7. Переменные окружения
env_file:
- .env
environment:
@@ -76,14 +79,24 @@ services:
# Передаем переменную с путем на хосте внутрь контейнера, чтобы sed мог её использовать
- HOST_PROJECT_PATH=${HOST_PROJECT_PATH:-/home/default_user/projects/dq-site}
# 7. Логирование (Ротация)
# 8. Проверка здоровья контейнера (Healthcheck)
# Docker будет периодически проверять статус контейнера. Это критично для Watchtower!
# Если контейнер объявлен "unhealthy", Watchtower сначала остановит старый образ, потом запустит новый.
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/').read()"]
interval: 30s # Проверка каждые 30 секунд
timeout: 3s # Таймаут ответа - 3 секунды
start_period: 10s # Даем 10 секунд на стартап перед первой проверкой
retries: 3 # Unhealthy после 3 неудачных попыток
# 9. Логирование (Ротация)
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 8. Ресурсы
# 10. Ресурсы
deploy:
resources:
limits:
@@ -106,8 +119,11 @@ services:
- REPO_PASS=${REPO_PASS}
- WATCHTOWER_SCOPE=dq-scope
- WATCHTOWER_CLEANUP=true # Удалять старые образы после обновления
- WATCHTOWER_POLL_INTERVAL=1800 # Проверять каждые 30 минут
- DOCKER_API_VERSION=1.44
# Дополнительные опции для правильной работы с healthcheck
- WATCHTOWER_WAIT_ON_TIMEOUT=60 # Ждем 60 сек пока контейнер станет healthy перед финализацией
- WATCHTOWER_LIFECYCLE_HOOKS=true # Включаем lifecycle hooks для graceful shutdown
command: --interval 1800 --cleanup # Проверять каждые 30 минут
logging:
driver: "json-file"
options:

37
poetry.lock generated
View File

@@ -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"
@@ -99,13 +128,13 @@ Django = ">=4.1"
[[package]]
name = "etpgrf"
version = "0.1.4"
version = "0.1.6"
description = "Electro-Typographer: Python library for advanced web typography (non-breaking spaces, hyphenation, hanging punctuation and ."
optional = false
python-versions = ">=3.10"
files = [
{file = "etpgrf-0.1.4-py3-none-any.whl", hash = "sha256:62d4371e1b5fab06b99f79bd351767aed8baf7d041cae7e5d4eb63f7c9545114"},
{file = "etpgrf-0.1.4.tar.gz", hash = "sha256:c699382c292e3110915331dd5539e7dde0c961e4f4ca65cf8db0e01e84dab72f"},
{file = "etpgrf-0.1.6-py3-none-any.whl", hash = "sha256:a2d2a67048f094e1d30fe42f05f420afd19babe66ec7daa35b517ca23306d5cc"},
{file = "etpgrf-0.1.6.tar.gz", hash = "sha256:a050c400a30be1c2379c892fc5fa398a79d15f0169094f00023a75dec01864af"},
]
[package.dependencies]
@@ -646,4 +675,4 @@ brotli = ["brotli"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "3d7a7f2fe8ec78993616e707e29e96503f134bd1cec48cac7f6dd47814863f4f"
content-hash = "64c804553b6314e8f8f5830637781a3179fd70f14cceb6730bfcb2cf24c91a31"

View File

@@ -1,26 +1,14 @@
# DicQuo
User-Agent: *
Allow: /
Disallow: /admin/
Disallow: /*?tag=
Disallow: /*?
# Optimize for Yandex
Clean-param: tag /
# AI and LLM bots settings
# OpenAI GPT
# User-agent: GPTBot
# Disallow:
# Common Crawl (used by many AI models)
# User-agent: CCBot
# Disallow:
# Google Bard/Gemini
# User-agent: Google-Extended
# Disallow:
Host: dq.cube2.ru
Sitemap: https://dq.cube2.ru/sitemap.xml
# Ссылка на файл для ИИ-моделей
Link: /llms.txt

View File

@@ -306,12 +306,17 @@ footer button:hover {
/* --- ВЫРАВНИВАНИЕ СИМВОЛОВ ВИСЯЧЕЙ ПУНКТУАЦИИ (Hanging Punctuation) ТИПОГРАФА ETPGRF --- */
/* --- В ПРОЕКТЕ ТОЛЬКО ЛЕВЫЕ ВИСЯЧИЕ СИМВОЛЫ (выравнивание по левому краю) --- */
.etp-laquo {margin-left: -0.44em;} /* « */
.etp-ldquo, .etp-bdquo { margin-left: -0.4em;} /* “ */
.etp-lsquo {margin-left: -0.22em;} /* */
.etp-lpar, .etp-lsqb, .etp-lcub {margin-left: -0.25em;}/* ( [ { */
.etp-laquo { margin-left: -0.49em; } /* « */
.etp-ldquo, .etp-bdquo { margin-left: -0.4em; } /* “ */
.etp-lsquo { margin-left: -0.22em; } /* */
.etp-lpar, .etp-lsqb, .etp-lcub { margin-left: -0.23em; } /* ( [ { */
/* компенсирующие пробелы для левых висячих символов */
.etp-sp-laquo { padding-right: 0.49em; }
.etp-sp-ldquo, .etp-sp-bdquo { padding-right: 0.4em; }
.etp-sp-lsquo { padding-right: 0.22em; }
.etp-sp-lpar, .etp-sp-lsqb, .etp-sp-lcub { padding-right: 0.23em; }
/* --- СЧЕТЧИКИ (СКРЫТЫЙ ПИКСЕЛЬ) --- */
/* --- СЧЕТЧИКИ (СКРЫТЫЙ ПИКСЕЛЬ) top.mail.ru и Яндекс.Метрика --- */
.counter-pixel {
border: 0;
position: absolute;

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

@@ -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)
})(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});
// 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');
})();

View File

@@ -12,11 +12,12 @@ django = "^6.0.2"
django-taggit = "^6.1.0"
pillow = "^12.1.1"
pytils = "^0.4.4"
etpgrf = "^0.1.4"
etpgrf = "0.1.6"
django-environ = "^0.12.1"
whitenoise = "^6.11.0"
gunicorn = "^25.1.0"
tqdm = "^4.67.3"
django-select2 = "^8.4.8"
[build-system]