domovoy_bot/web/templates/broadcast.html
Admin 07d7a51f10 🚨 fix: Критические исправления рассылок
- Глобальный синглтон бота (bot_instance.py) — работает из любого процесса
- Прямые HTTP запросы в send_smart_broadcast — нет ошибки Timeout context
- Исправлен joinedload в InitiativeGroupService
- Удалён дубликат route /broadcast
- Удалена страница /broadcasts — всё в одном месте с табами
- Удалён /api/broadcast/send — дублировал /api/broadcast/create
- Добавлен dry_run режим для тестирования без отправки
- Двойное подтверждение перед реальной рассылкой
- Добавлен файл КРИТИЧЕСКИЕ-ПРАВИЛА.md

Инцидент: тестовая рассылка ушла реальным пользователям

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-12 17:57:18 +04:00

413 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}Рассылки - Домовой Бот{% endblock %}
{% block nav_broadcast %}active{% endblock %}
{% block extra_style %}
<style>
#preview { max-width: 400px; margin-top: 1rem; display: none; }
#preview img { max-width: 100%; border-radius: 8px; }
.smart-options { display: none; background: #f0f7ff; padding: 20px; border-radius: 8px; margin-top: 15px; border: 2px solid #667eea; }
.smart-options.active { display: block; }
.broadcast-type-selector { display: flex; gap: 15px; margin-bottom: 20px; }
.broadcast-type-option { flex: 1; padding: 20px; border: 2px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.3s; text-align: center; }
.broadcast-type-option:hover { border-color: #667eea; background: #f8f9ff; }
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; }
.broadcast-type-option h4 { margin-bottom: 10px; }
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; }
.nav-tabs .nav-link { color: #495057; }
.nav-tabs .nav-link.active { font-weight: bold; color: #667eea; }
.broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
.badge-regular { background: #6c757d; color: white; }
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; }
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); }
.readers-table { font-size: 0.9em; }
.readers-table th { background: #f8f9fa; }
</style>
{% endblock %}
{% block content %}
<h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылки</h2>
<!-- Табы -->
<ul class="nav nav-tabs mb-4" id="broadcastTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="create-tab" data-bs-toggle="tab" data-bs-target="#create" type="button" role="tab">
<i class="bi bi-plus-circle"></i> Создать рассылку
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history"></i> История и статистика
{% if broadcasts %}
<span class="badge bg-primary ms-1">{{ broadcasts|length }}</span>
{% endif %}
</button>
</li>
</ul>
<div class="tab-content" id="broadcastTabContent">
<!-- ВКЛАДКА: Создание рассылки -->
<div class="tab-pane fade show active" id="create" role="tabpanel">
<div class="card">
<div class="card-body">
<!-- Тип рассылки -->
<label class="form-label fw-bold">Тип рассылки:</label>
<div class="broadcast-type-selector">
<div class="broadcast-type-option active" onclick="selectBroadcastType('smart')" id="typeSmart">
<div class="icon"></div>
<h4>Умная</h4>
<p class="text-muted small">С отслеживанием прочтений и статистикой</p>
</div>
</div>
<form id="broadcastForm" enctype="multipart/form-data">
<input type="hidden" id="broadcastType" value="smart">
<div class="mb-3">
<label class="form-label">Текст рассылки</label>
<textarea class="form-control" id="broadcastText" rows="5"
placeholder="Введите текст рассылки..." required></textarea>
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
</div>
<div class="mb-3">
<label class="form-label">Картинка (необязательно)</label>
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small>
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
</div>
<div class="mb-3">
<label class="form-label">Кому отправить:</label>
<select class="form-select" id="broadcastRecipients">
<option value="all_and_chat">✅ Всем верифицированным + в чат</option>
<option value="all_verified">✅ Только верифицированным в личку</option>
<option value="ig_only">👥 Только Инициативной Группе</option>
</select>
</div>
<!-- Опции умной рассылки (всегда включены) -->
<div class="smart-options active">
<h5 class="mb-3"><i class="bi bi-stars"></i> Возможности умной рассылки</h5>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i>
<b>После отправки вы сможете:</b>
<ul class="mb-0 mt-2">
<li>Посмотреть кто прочитал сообщение</li>
<li>Увидеть кто НЕ прочитал</li>
<li>Отправить напоминание непрочитавшим</li>
<li>Просмотреть общую статистику во вкладке "История"</li>
</ul>
</div>
</div>
<button type="button" class="btn btn-outline-secondary btn-lg mt-3 me-2" onclick="testBroadcast()">
<i class="bi bi-shield-check"></i> 🧪 Тест (без отправки)
</button>
<button type="submit" class="btn btn-primary btn-lg mt-3">
<i class="bi bi-send"></i> ✨ Отправить умную рассылку
</button>
</form>
<div id="result" class="mt-4"></div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<h5>Как использовать:</h5>
<ol>
<li>Введите текст объявления</li>
<li>Прикрепите картинку (необязательно)</li>
<li>Выберите получателей</li>
<li>Нажмите "Отправить умную рассылку"</li>
</ol>
</div>
</div>
</div>
<!-- ВКЛАДКА: История и статистика -->
<div class="tab-pane fade" id="history" role="tabpanel">
{% if broadcasts %}
<div id="broadcastsList">
{% for b in broadcasts %}
<div class="broadcast-item">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span class="badge {{ 'badge-smart' if b.broadcast_type == 'smart' else 'badge-regular' }}">
{{ '✨ Умная' if b.broadcast_type == 'smart' else '📢 Обычная' }}
</span>
<strong class="ms-2">#{{ b.id }}</strong>
</div>
<small class="text-muted">{{ b.sent_at }}</small>
</div>
<p class="mb-2">{{ b.text[:150] }}{{ '...' if b.text|length > 150 else '' }}</p>
<div class="d-flex justify-content-between align-items-center">
<div>
<small class="text-muted">
📤 Отправлено: <b>{{ b.total_sent }}</b> |
✅ Прочитали: <b>{{ b.read_count }}</b> |
Не прочитали: <b>{{ b.unread_count }}</b>
</small>
{% if b.total_sent > 0 %}
<div class="progress-mini mt-1">
<div class="progress-mini-fill" style="width: {{ b.read_percent }}%"></div>
</div>
<small class="ms-1">{{ b.read_percent }}%</small>
{% endif %}
</div>
{% if b.broadcast_type == 'smart' %}
<div>
<button class="btn btn-sm btn-outline-success" onclick="showReaders({{ b.id }}, '{{ b.text[:50] }}')">
<i class="bi bi-eye"></i> Кто прочитал
</button>
{% if not b.is_reminder_sent %}
<button class="btn btn-sm btn-outline-warning" onclick="sendReminder({{ b.id }})">
<i class="bi bi-bell"></i> Напомнить
</button>
{% else %}
<small class="text-muted ms-2"><i class="bi bi-check-circle"></i> Напоминание отправлено</small>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Рассылок пока нет. Создайте первую во вкладке "Создать рассылку"!
</div>
{% endif %}
</div>
</div>
<!-- Модальное окно: кто прочитал -->
<div class="modal fade" id="readersModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="readersModalTitle">📊 Кто прочитал</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<ul class="nav nav-tabs mb-3" id="readersTabs" role="tablist">
<li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#readersRead">✅ Прочитали</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#readersUnread">Не прочитали</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="readersRead">
<div id="readersList" class="readers-table">Загрузка...</div>
</div>
<div class="tab-pane fade" id="readersUnread">
<div id="nonReadersList" class="readers-table">Загрузка...</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_script %}
<script>
let currentBroadcastId = null;
// Выбор типа рассылки (теперь только умная)
function selectBroadcastType(type) {
document.getElementById('broadcastType').value = type;
}
// Предпросмотр изображения
function previewImage() {
const file = document.getElementById('broadcastPhoto').files[0];
const preview = document.getElementById('preview');
const previewImg = document.getElementById('previewImg');
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
preview.style.display = 'block';
}
reader.readAsDataURL(file);
} else {
preview.style.display = 'none';
}
}
// Тестовая рассылка (без отправки!)
async function testBroadcast() {
const text = document.getElementById('broadcastText').value;
const recipients = document.getElementById('broadcastRecipients').value;
const resultDiv = document.getElementById('result');
if (!text.trim()) {
alert('Введите текст рассылки!');
return;
}
resultDiv.innerHTML = '<div class="alert alert-info">🧪 Тестирование (без отправки)...</div>';
const formData = new FormData();
formData.append('text', text);
formData.append('recipients', recipients);
formData.append('has_read_button', 'true');
formData.append('dry_run', 'true');
try {
const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = `
<div class="alert alert-success">
✅ <b>Тест успешен!</b><br><br>
📊 Рассылка получила бы: <b>${result.recipients_count} пользователей</b><br>
👥 Получатели: ${recipients}<br><br>
<i>Сообщения НЕ отправлялись</i>
</div>
`;
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
}
}
// Отправка формы
document.getElementById('broadcastForm').addEventListener('submit', async (e) => {
e.preventDefault();
const text = document.getElementById('broadcastText').value;
const photo = document.getElementById('broadcastPhoto').files[0];
const recipients = document.getElementById('broadcastRecipients').value;
const resultDiv = document.getElementById('result');
if (!text.trim()) {
alert('Введите текст рассылки!');
return;
}
// Двойное подтверждение для реальной отправки
const confirmed = confirm(
'⚠️ ВНИМАНИЕ! Это отправит сообщения реальным пользователям!\n\n' +
`Получатели: ${recipients}\n\n` +
'Вы уверены?'
);
if (!confirmed) return;
resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>';
const formData = new FormData();
formData.append('text', text);
formData.append('recipients', recipients);
formData.append('has_read_button', 'true');
formData.append('track_reads', 'true');
if (photo) {
formData.append('photo', photo);
}
try {
const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br><br>📊 Перейдите во вкладку "История и статистика" для просмотра результатов →</div>`;
document.getElementById('broadcastForm').reset();
document.getElementById('preview').style.display = 'none';
// Перезагружаем страницу через 2 секунды чтобы обновить историю
setTimeout(() => location.reload(), 2000);
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
}
} catch (err) {
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
}
});
// Показать кто прочитал/не прочитал
async function showReaders(broadcastId, title) {
currentBroadcastId = broadcastId;
document.getElementById('readersModalTitle').textContent = '📊 ' + title;
const modal = new bootstrap.Modal(document.getElementById('readersModal'));
modal.show();
// Загружаем данные
try {
const [readersResp, nonReadersResp] = await Promise.all([
fetch(`/api/broadcast/readers/${broadcastId}`),
fetch(`/api/broadcast/non_readers/${broadcastId}`)
]);
const readers = await readersResp.json();
const nonReaders = await nonReadersResp.json();
// Прочитали
if (readers.readers && readers.readers.length > 0) {
document.getElementById('readersList').innerHTML = readers.readers.map(r => `
<div class="d-flex justify-content-between p-2 border-bottom">
<div>
<strong>${r.name || 'user' + r.user_id}</strong>
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
</div>
<small class="text-muted">${r.read_at}</small>
</div>
`).join('');
} else {
document.getElementById('readersList').innerHTML = '<p class="text-muted">Пока никто не прочитал</p>';
}
// Не прочитали
if (nonReaders.non_readers && nonReaders.non_readers.length > 0) {
document.getElementById('nonReadersList').innerHTML = nonReaders.non_readers.map(r => `
<div class="d-flex justify-content-between p-2 border-bottom">
<div>
<strong>${r.name || 'user' + r.user_id}</strong>
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
</div>
</div>
`).join('');
} else {
document.getElementById('nonReadersList').innerHTML = '<p class="text-success"><i class="bi bi-check-circle"></i> Все прочитали!</p>';
}
} catch (err) {
console.error('Ошибка загрузки данных:', err);
}
}
// Отправить напоминание
async function sendReminder(broadcastId) {
if (!confirm('Отправить напоминание непрочитавшим?')) return;
try {
const resp = await fetch(`/api/broadcast/send_reminder/${broadcastId}`, { method: 'POST' });
const result = await resp.json();
if (result.success) {
alert('✅ ' + result.message);
location.reload();
} else {
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
}
} catch (err) {
alert('❌ Ошибка: ' + err.message);
}
}
</script>
{% endblock %}