5 Commits

15 changed files with 323 additions and 30 deletions

View File

@@ -59,6 +59,15 @@ http {
# Убираем токены версии nginx для безопасности # Убираем токены версии nginx для безопасности
server_tokens off; server_tokens off;
# Прямая раздача favicon.ico (для поисковиков и браузеров)
# Это быстрее и надежнее, чем редирект через Django
location = /favicon.ico {
alias /app/public/static_collected/favicon.ico;
access_log off;
log_not_found off;
expires 30d;
}
location / { location / {
proxy_pass http://app_server; proxy_pass http://app_server;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -12,8 +12,8 @@ services:
labels: labels:
- "com.centurylinklabs.watchtower.scope=etpgrf" - "com.centurylinklabs.watchtower.scope=etpgrf"
# Запускаем collectstatic перед стартом # Запускаем миграции, потом collectstatic, потом сервер
command: sh -c "python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi" command: sh -c "python etpgrf_site/manage.py migrate --noinput && python etpgrf_site/manage.py collectstatic --noinput && gunicorn --bind 0.0.0.0:8000 --chdir /app/etpgrf_site etpgrf_site.wsgi"
volumes: volumes:
# База данных (папка data должна быть создана на хосте) # База данных (папка data должна быть создана на хосте)
@@ -72,7 +72,6 @@ services:
# Берем учетные данные из .env файла # Берем учетные данные из .env файла
- REPO_USER=${REPO_USER} - REPO_USER=${REPO_USER}
- REPO_PASS=${REPO_PASS} - REPO_PASS=${REPO_PASS}
- WATCHTOWER_REGISTRY_URL=git.cube2.ru
# Ограничиваем область видимости только этим проектом # Ограничиваем область видимости только этим проектом
- WATCHTOWER_SCOPE=etpgrf - WATCHTOWER_SCOPE=etpgrf
# Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа) # Если нужно указать реестр явно (обычно watchtower сам понимает из имени образа)

9
etpgrf_site/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
"""сайт etpgrf - сайт на django для тестирования и представления библиотеки экранной типографики etpgrf.
Основные возможности:
- Веб-интерфейс для ввода текста и настройки параметров типографики.
"""
__version__ = "0.1.3"
__author__ = "Sergei Erjemin"
__email__ = "erjemin@gmail.com"
__license__ = "MIT"
__copyright__ = "Copyright 2026 Sergei Erjemin"

View File

@@ -1,3 +1,33 @@
from django.contrib import admin from django.contrib import admin
from .models import DailyStat
# Register your models here. @admin.register(DailyStat)
class DailyStatAdmin(admin.ModelAdmin):
list_display = (
'date',
'index_views',
'process_requests',
'copy_count',
'chars_in',
'chars_out',
'chars_copied',
'avg_processing_time_ms_formatted',
)
list_filter = ('date',)
search_fields = ('date',)
ordering = ('-date',)
# Делаем поля только для чтения
readonly_fields = [field.name for field in DailyStat._meta.fields]
def has_add_permission(self, request):
# Запрещаем добавлять записи вручную
return False
def has_delete_permission(self, request, obj=None):
# Запрещаем удалять записи
return False
@admin.display(description='Среднее время (мс)', ordering='total_processing_time_ms')
def avg_processing_time_ms_formatted(self, obj):
return f"{obj.avg_processing_time_ms:.2f}"

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0.1 on 2026-01-20 16:23
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='DailyStat',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(default=django.utils.timezone.now, unique=True, verbose_name='Дата')),
('index_views', models.PositiveIntegerField(default=0, verbose_name='Просмотры главной')),
('process_requests', models.PositiveIntegerField(default=0, verbose_name='Запросы на обработку')),
('chars_in', models.BigIntegerField(default=0, verbose_name='Символов на входе')),
('chars_out', models.BigIntegerField(default=0, verbose_name='Символов на выходе')),
('total_processing_time_ms', models.FloatField(default=0.0, verbose_name='Суммарное время обработки (мс)')),
('settings_stats', models.JSONField(default=dict, verbose_name='Статистика настроек')),
],
options={
'verbose_name': 'Дневная статистика',
'verbose_name_plural': 'Дневная статистика',
'ordering': ['-date'],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-20 19:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('typograph', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='dailystat',
name='copy_count',
field=models.PositiveIntegerField(default=0, verbose_name='Копирований в буфер'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-01-20 20:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('typograph', '0002_dailystat_copy_count'),
]
operations = [
migrations.AddField(
model_name='dailystat',
name='chars_copied',
field=models.BigIntegerField(default=0, verbose_name='Символов скопировано'),
),
]

View File

@@ -1,3 +1,67 @@
from django.db import models from django.db import models
from django.utils import timezone
# Create your models here. class DailyStat(models.Model):
"""
Модель для хранения агрегированной статистики использования за день.
"""
date = models.DateField(
verbose_name="Дата",
unique=True,
default=timezone.now
)
# Основные метрики
index_views = models.PositiveIntegerField(
verbose_name="Просмотры главной",
default=0
)
process_requests = models.PositiveIntegerField(
verbose_name="Запросы на обработку",
default=0
)
copy_count = models.PositiveIntegerField(
verbose_name="Копирований в буфер",
default=0
)
# Объемы
chars_in = models.BigIntegerField(
verbose_name="Символов на входе",
default=0
)
chars_out = models.BigIntegerField(
verbose_name="Символов на выходе",
default=0
)
chars_copied = models.BigIntegerField(
verbose_name="Символов скопировано",
default=0
)
# Производительность
total_processing_time_ms = models.FloatField(
verbose_name="Суммарное время обработки (мс)",
default=0.0
)
# Статистика по использованным настройкам
settings_stats = models.JSONField(
verbose_name="Статистика настроек",
default=dict
)
class Meta:
verbose_name = "Дневная статистика"
verbose_name_plural = "Дневная статистика"
ordering = ['-date']
def __str__(self):
return f"Статистика за {self.date.strftime('%Y-%m-%d')}"
@property
def avg_processing_time_ms(self):
"""Среднее время обработки одного запроса."""
if self.process_requests == 0:
return 0.0
return self.total_processing_time_ms / self.process_requests

View File

@@ -62,10 +62,18 @@
</div> </div>
{# Футер #} {# Футер #}
<footer class="footer mt-auto py-3"> <footer class="footer mt-auto py-2 mt-4">
<div class="container"> <div class="container d-flex justify-content-between align-items-center">
<span class="text-muted small">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span> <span class="text-muted small nowrap">&copy; Sergei Erjemin, 2025&ndash;{% now 'Y' %}.</span>
<span class="text-muted small float-end">v0.1.2</span>
<span class="text-muted small nowrap"><i class="bi bi-tags me-1" title="Версия библиотеки etpgrf / Версия сайта"></i>v0.1.3 / v0.1.3</span>
{# Сводная статистика (HTMX) #}
<span class="text-muted small" hx-get="{% url 'stats_summary' %}" hx-trigger="load">
...
</span>
</div> </div>
</footer> </footer>

View File

@@ -269,7 +269,7 @@
</form> </form>
</div> </div>
<div class="col-md-12 mt-4"> <div class="col-md-12 my-4">
<div class="d-flex justify-content-between align-items-end mb-2"> <div class="d-flex justify-content-between align-items-end mb-2">
<label class="form-label fw-bold small text-muted ls-1 mb-0"> <label class="form-label fw-bold small text-muted ls-1 mb-0">
<i class="bi bi-code-slash me-1"></i> Результат обработки: <i class="bi bi-code-slash me-1"></i> Результат обработки:

View File

@@ -0,0 +1,12 @@
<span class="me-3" title="Просмотров">
<i class="bi bi-eye me-1"></i>{{ views }}
</span>
<span class="me-3 nowrap" title="На вход обработано текстов/символов">
<i class="bi bi-box-arrow-in-right me-1"></i>{{ processed }}/{{ chars_in }}
</span>
<span class="me-3" title="На выход получено символов">
<i class="bi bi-box-arrow-right me-1"></i>{{ chars_out }}
</span>
<span class="nowrap" title="Скопировано в буфер текстов/символов">
<i class="bi bi-clipboard-check me-1"></i>{{ copied }}/{{ chars_copied }}
</span>

View File

@@ -2,6 +2,8 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path(route='', view=views.index, name='index'), path('', views.index, name='index'),
path(route='process/', view=views.process_text, name='process_text'), path('process/', views.process_text, name='process_text'),
path('stats/summary/', views.get_stats_summary, name='stats_summary'),
path('stats/track-copy/', views.track_copy, name='track_copy'),
] ]

View File

@@ -1,19 +1,86 @@
from django.shortcuts import render from django.shortcuts import render
from django.http import HttpResponse from django.http import HttpResponse, JsonResponse
from django.db.models import F, Sum
from django.utils import timezone
from django.views.decorators.http import require_POST
from etpgrf.typograph import Typographer from etpgrf.typograph import Typographer
from etpgrf.layout import LayoutProcessor from etpgrf.layout import LayoutProcessor
from etpgrf.hyphenation import Hyphenator from etpgrf.hyphenation import Hyphenator
from .models import DailyStat
def index(request): def index(request):
# Увеличиваем счетчик просмотров главной
try:
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
DailyStat.objects.filter(pk=stat.pk).update(index_views=F('index_views') + 1)
except Exception as e:
print(f"Stat error: {e}")
return render(request, template_name='typograph/index.html') return render(request, template_name='typograph/index.html')
def get_stats_summary(request):
"""Возвращает сводную статистику."""
# Убираем try...except для отладки
stats = DailyStat.objects.aggregate(
views=Sum('index_views'),
processed=Sum('process_requests'),
copied=Sum('copy_count'),
chars_in=Sum('chars_in'),
chars_out=Sum('chars_out'),
chars_copied=Sum('chars_copied')
)
# print("Aggregated stats:", stats) # DEBUG
# Функция для форматирования чисел с сокращениями (M, k)
def format_large_number(num):
if num > 1_000_000:
return f"{num / 1_000_000:.3f}M".replace(".", ",")
elif num > 1_000:
return f"{num / 1_000:.2f}k".replace(".", ",")
return str(num)
context = {
'views': f"{(stats['views'] or 0):,}".replace(",", "&thinsp;"),
'processed': f"{(stats['processed'] or 0):,}".replace(",", "&thinsp;"),
'copied': f"{(stats['copied'] or 0):,}".replace(",", "&thinsp;"),
'chars_in': format_large_number(stats['chars_in'] or 0),
'chars_out': format_large_number(stats['chars_out'] or 0),
'chars_copied': format_large_number(stats['chars_copied'] or 0),
}
return render(request, 'typograph/stats_summary.html', context)
@require_POST
def track_copy(request):
"""Увеличивает счетчик копирований и количество скопированных символов."""
try:
char_count = int(request.POST.get('char_count', 0))
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
DailyStat.objects.filter(pk=stat.pk).update(
copy_count=F('copy_count') + 1,
chars_copied=F('chars_copied') + char_count
)
return HttpResponse("OK")
except (ValueError, TypeError):
return HttpResponse("Invalid char_count", status=400)
except Exception as e:
print(f"Stat error: {e}")
return HttpResponse("Error", status=500)
def process_text(request): def process_text(request):
if request.method == 'POST': if request.method == 'POST':
text = request.POST.get(key='text', default='') text = request.POST.get(key='text', default='')
# 1. Читаем базовые настройки # 1. Читаем базовые настройки
langs = request.POST.get(key='langs', default='ru') langs = request.POST.get(key='langs', default='ru')
# 2. Собираем LayoutProcessor # 2. Собираем LayoutProcessor
layout_enabled = request.POST.get(key='layout') == 'on' layout_enabled = request.POST.get(key='layout') == 'on'
layout_option = False layout_option = False
@@ -23,13 +90,13 @@ def process_text(request):
custom_units = request.POST.get(key='layout_units_custom', default='').strip() custom_units = request.POST.get(key='layout_units_custom', default='').strip()
if custom_units: if custom_units:
process_units = custom_units.split() process_units = custom_units.split()
layout_option = LayoutProcessor( layout_option = LayoutProcessor(
langs=langs, langs=langs,
process_initials_and_acronyms=request.POST.get(key='layout_initials') == 'on', process_initials_and_acronyms=request.POST.get(key='layout_initials') == 'on',
process_units=process_units process_units=process_units
) )
# 3. Собираем Hyphenator # 3. Собираем Hyphenator
hyphenation_enabled = request.POST.get(key='hyphenation') == 'on' hyphenation_enabled = request.POST.get(key='hyphenation') == 'on'
hyphenation_option = False hyphenation_option = False
@@ -39,7 +106,7 @@ def process_text(request):
max_len = int(max_len) max_len = int(max_len)
except (ValueError, TypeError): except (ValueError, TypeError):
max_len = 12 max_len = 12
hyphenation_option = Hyphenator( hyphenation_option = Hyphenator(
langs=langs, langs=langs,
max_unhyphenated_len=max_len max_unhyphenated_len=max_len
@@ -70,23 +137,40 @@ def process_text(request):
'mode': request.POST.get(key='mode', default='mixed'), 'mode': request.POST.get(key='mode', default='mixed'),
'sanitizer': sanitizer_option, 'sanitizer': sanitizer_option,
} }
# --- ДИАГНОСТИКА --- # --- ДИАГНОСТИКА ---
# print("Typographer options:", options) # print("Typographer options:", options)
# ------------------- # -------------------
# Создаем экземпляр типографа # Создаем экземпляр типографа
typo = Typographer(**options) typo = Typographer(**options)
# Обрабатываем текст # Обрабатываем текст
processed = typo.process(text) processed = typo.process(text)
# print("Processed text length:", len(processed))
# print("Processed text:", processed)
# --- СБОР СТАТИСТИКИ ---
try:
today = timezone.now().date()
stat, created = DailyStat.objects.get_or_create(date=today)
# Обновляем атомарные поля
DailyStat.objects.filter(pk=stat.pk).update(
process_requests=F('process_requests') + 1,
chars_in=F('chars_in') + len(text),
chars_out=F('chars_out') + len(processed),
# total_processing_time_ms мы пока не считаем, чтобы не усложнять
)
# JSON с настройками пока не пишем, чтобы не усложнять (как договаривались)
except Exception as e:
print(f"Stat error: {e}")
# -----------------------
return render( return render(
request, request,
template_name='typograph/result_fragment.html', template_name='typograph/result_fragment.html',
context={'processed_text': processed} context={'processed_text': processed}
) )
return HttpResponse(status=405) return HttpResponse(status=405)

View File

@@ -22,8 +22,6 @@ const resultWrapper = document.getElementById('cm-result-wrapper');
const btnCopy = document.getElementById('btn-copy'); const btnCopy = document.getElementById('btn-copy');
const sourceTextarea = document.querySelector('textarea[name="text"]'); const sourceTextarea = document.querySelector('textarea[name="text"]');
// console.log("Index.js loaded. btnCopy:", !!btnCopy, "sourceTextarea:", !!sourceTextarea); // DEBUG
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
function getTheme() { function getTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? oneDark : []; return window.matchMedia('(prefers-color-scheme: dark)').matches ? oneDark : [];
@@ -90,7 +88,6 @@ const resultView = new EditorView({
// Обработка ответа от сервера (HTMX) // Обработка ответа от сервера (HTMX)
document.body.addEventListener('htmx:afterSwap', function (evt) { document.body.addEventListener('htmx:afterSwap', function (evt) {
// console.log("HTMX afterSwap event:", evt.detail.target.id); // DEBUG
if (evt.detail.target.id === 'result-area') { if (evt.detail.target.id === 'result-area') {
const newContent = evt.detail.xhr.response; const newContent = evt.detail.xhr.response;
@@ -101,7 +98,6 @@ document.body.addEventListener('htmx:afterSwap', function (evt) {
// Показываем кнопку копирования // Показываем кнопку копирования
if (btnCopy) { if (btnCopy) {
// console.log("Showing copy button"); // DEBUG
btnCopy.classList.remove('d-none'); btnCopy.classList.remove('d-none');
} }
} }
@@ -132,13 +128,24 @@ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()
if (btnCopy) { if (btnCopy) {
btnCopy.addEventListener('click', async () => { btnCopy.addEventListener('click', async () => {
const text = resultView.state.doc.toString(); const text = resultView.state.doc.toString();
// console.log("Copying text:", text.substring(0, 20) + "..."); // DEBUG
// Отправляем цель в метрику // Отправляем цель в метрику
if (typeof window.sendGoal === 'function') { if (typeof window.sendGoal === 'function') {
window.sendGoal('etpgrf-copy-pressed'); window.sendGoal('etpgrf-copy-pressed');
} }
// Отправляем статистику на сервер
const ch_count_copy2clipboard = new FormData();
ch_count_copy2clipboard.append('char_count', text.length);
fetch('/stats/track-copy/', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
},
body: ch_count_copy2clipboard
}).catch(err => console.error("Ошибка отправки статистики:", err));
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);