v1.20 ИГ кнопка + логирование + настройка в broadcast

🎯 ИНИЦИАТИВНАЯ ГРУППА:
• Кнопка 'ИГ' в дашборде и пользователях
• Фильтр по ИГ в /users?filter=ig
• Синяя карточка для ИГ

⚙️ НАСТРОЙКИ:
• Перенесено из /settings в /broadcast
• Лимит рассылки ИГ (секунды)
• GET/POST /api/settings/ig_broadcast_limit

📊 ЛОГИРОВАНИЕ:
• Таблица activity_log
• Логирование рассылок
• API /api/activity_log

🐛 ИСПРАВЛЕНИЯ:
• Удалена /settings (Internal Server Error)
• Исправлен web/app.py (повреждённый код)

📁 ФАЙЛЫ:
• web/app.py: фильтр ИГ, логи, настройка
• web/templates/dashboard.html: кнопка ИГ
• web/templates/users.html: кнопка ИГ
• web/templates/broadcast.html: настройка лимита
• database: activity_log table

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Admin 2026-03-10 17:10:26 +00:00
parent cef2ac3714
commit ff03e38ee7
5 changed files with 163 additions and 142 deletions

View file

@ -87,6 +87,13 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
stmt = stmt.where(User.verified == False)
elif filter == 'active_unverified':
stmt = stmt.where(User.verified == False, User.message_count >= 50)
elif filter == 'ig':
# Только ИГ
from database.models import InitiativeGroup
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_result = await session.execute(ig_stmt)
ig_user_ids = [row[0] for row in ig_result.all()]
stmt = stmt.where(User.user_id.in_(ig_user_ids))
# filter == 'all' или пустой — показываем всех
# Поиск
@ -114,10 +121,11 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
active_unverified = (await session.execute(
select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50)
)).scalar()
ig_count = len(ig_user_ids)
return templates.TemplateResponse("users.html",
{"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
"stats": {"total": total_count, "verified": verified_count, "unverified": unverified_count, "active_unverified": active_unverified},
"stats": {"total": total_count, "verified": verified_count, "unverified": unverified_count, "active_unverified": active_unverified, "ig": ig_count},
"ig_user_ids": ig_user_ids})
@ -593,6 +601,22 @@ async def api_send_broadcast(
except Exception as e:
logger.error(f"Ошибка отправки в чат: {e}")
# Логируем в базу
try:
from sqlalchemy import text
async with AsyncSessionLocal() as session:
await session.execute(text('''
INSERT INTO activity_log (user_id, action_type, action_data, recipients_count)
VALUES (:user_id, 'broadcast', :data, :count)
'''), {
"user_id": config.ADMIN_USER_ID,
"data": f"recipients={recipients}, text_len={len(text)}",
"count": success_count
})
await session.commit()
except Exception as e:
logger.error(f"Ошибка логирования рассылки: {e}")
return JSONResponse({
"success": True,
"message": f"Отправлено: {success_count} (в чат: {'' if chat_sent else ''})",
@ -605,6 +629,44 @@ async def api_send_broadcast(
})
# ============================================================================
# ЛОГИРОВАНИЕ АКТИВНОСТИ
# ============================================================================
@app.get("/api/activity_log")
async def api_get_activity_log(
username: str = Depends(get_current_admin),
action_type: str = "",
days: int = 30
):
"""Получить логи активности"""
from sqlalchemy import text, desc
async with AsyncSessionLocal() as session:
stmt = text('''
SELECT * FROM activity_log
WHERE (:action_type = '' OR action_type = :action_type)
AND created_at >= datetime('now', '-' || :days || ' days')
ORDER BY created_at DESC
LIMIT 100
''')
result = await session.execute(stmt, {"action_type": action_type, "days": str(days)})
logs = [
{
"id": row[0],
"user_id": row[1],
"action_type": row[2],
"action_data": row[3],
"recipients_count": row[4],
"created_at": row[5]
}
for row in result.all()
]
return JSONResponse({"success": True, "logs": logs})
# ============================================================================
# ОПРОСЫ
# ============================================================================
@ -744,22 +806,9 @@ async def api_export_json(username: str = Depends(get_current_admin)):
# ============================================================================
# НАСТРОЙКИ
# НАСТРОЙКИ (перенесено в /broadcast)
# ============================================================================
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request, username: str = Depends(get_current_admin)):
"""Страница настроек"""
async with AsyncSessionLocal() as session:
from sqlalchemy import text
result = await session.execute(text("SELECT key, value FROM settings"))
settings = {row[0]: row[1] for row in result.all()}
return templates.TemplateResponse("settings.html", {
"request": request,
"username": username,
"settings": settings
})
# Настройка лимита рассылки ИГ теперь в /broadcast
@app.post("/api/settings/ig_broadcast_limit")
@ -781,19 +830,24 @@ async def api_set_ig_broadcast_limit(
return JSONResponse({"success": True, "message": f"Лимит установлен: {limit} сек"})
@app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request, username: str = Depends(get_current_admin)):
"""Настройки"""
return templates.TemplateResponse("settings.html", {
"request": request, "username": username,
"config": {
"bot_token": config.BOT_TOKEN[:20] + "...",
"admin_chat_id": config.ADMIN_CHAT_ID,
"admin_user_id": config.ADMIN_USER_ID,
"use_proxy": config.USE_PROXY,
"web_login": config.WEB_ADMIN_LOGIN
}
})
@app.get("/api/settings/ig_broadcast_limit")
async def api_get_ig_broadcast_limit(
username: str = Depends(get_current_admin)
):
"""Получить лимит рассылки ИГ"""
from sqlalchemy import text
async with AsyncSessionLocal() as session:
result = await session.execute(text('''
SELECT value FROM settings WHERE key = 'ig_broadcast_limit'
'''))
row = result.first()
if row:
return JSONResponse({"success": True, "value": row[0]})
else:
return JSONResponse({"success": True, "value": "0"})
def run_web_server(host: str = "0.0.0.0", port: int = 8000):

View file

@ -91,17 +91,85 @@
<ol>
<li>Введите текст объявления</li>
<li>Прикрепите картинку (необязательно)</li>
<li>Выберите получателей</li>
<li>Нажмите "Отправить рассылку"</li>
<li>Бот отправит всем верифицированным пользователям в личку</li>
<li>Также отправит в общий чат</li>
<li>Бот отправит выбранным получателям</li>
</ol>
</div>
</div>
<!-- IG Broadcast Limit -->
<div class="card mt-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-people"></i> Настройка рассылки ИГ</h5>
</div>
<div class="card-body">
<form id="igLimitForm">
<div class="mb-3">
<label class="form-label">Минимальный интервал между рассылками ИГ (секунды)</label>
<input type="number" class="form-control" id="igBroadcastLimit" value="0" min="0" step="60">
<small class="text-muted">
<ul class="mb-0">
<li><code>0</code> — Без ограничений</li>
<li><code>3600</code> — 1 раз в час</li>
<li><code>86400</code> — 1 раз в сутки</li>
</ul>
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить настройку
</button>
</form>
<div id="igLimitResult" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Загрузка настройки лимита ИГ
async function loadIgLimit() {
try {
const r = await fetch('/api/settings/ig_broadcast_limit');
const result = await r.json();
if (result.success) {
document.getElementById('igBroadcastLimit').value = parseInt(result.value) || 0;
}
} catch (err) {
console.error('Ошибка загрузки настройки:', err);
}
}
// Сохранение настройки лимита ИГ
document.getElementById('igLimitForm').addEventListener('submit', async (e) => {
e.preventDefault();
const limit = parseInt(document.getElementById('igBroadcastLimit').value);
const resultDiv = document.getElementById('igLimitResult');
resultDiv.innerHTML = '<div class="alert alert-info">💾 Сохранение...</div>';
try {
const r = await fetch('/api/settings/ig_broadcast_limit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: limit })
});
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = '<div class="alert alert-success">✅ Настройка сохранена!</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.addEventListener('DOMContentLoaded', loadIgLimit);
// Обновление информации о получателях
document.getElementById('broadcastRecipients').addEventListener('change', function() {
const info = {

View file

@ -138,10 +138,10 @@
</a>
</div>
<div class="col-md-3">
<a href="/users?filter=unverified" style="text-decoration: none;">
<div class="stat-card warning">
<div class="stat-value">{{ stats.unverified }}</div>
<div class="stat-label">Не верифицировано</div>
<a href="/users?filter=ig" style="text-decoration: none;">
<div class="stat-card" style="background: linear-gradient(135deg, #17a2b8, #117a8b);">
<div class="stat-value">{{ stats.ig }}</div>
<div class="stat-label">Инициативная группа</div>
</div>
</a>
</div>

View file

@ -1,103 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Настройки - Домовой Бот</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar { min-height: 100vh; background: linear-gradient(180deg, #2c3e50 0%, #1a252f 100%); }
.sidebar .nav-link { color: rgba(255,255,255,0.8); padding: 1rem; }
.sidebar .nav-link:hover, .sidebar .nav-link.active { color: white; background-color: rgba(255,255,255,0.1); }
.content { padding: 2rem; }
</style>
</head>
<body>
<div class="container-fluid">
<div class="row">
<div class="col-md-2 sidebar p-0">
<div class="p-3 text-white text-center">
<h4><i class="bi bi-house-door"></i> Домовой Бот</h4>
</div>
<nav class="nav flex-column">
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Дашборд</a>
<a class="nav-link" href="/users"><i class="bi bi-people"></i> Пользователи</a>
<a class="nav-link" href="/verification"><i class="bi bi-shield-check"></i> Верификация</a>
<a class="nav-link" href="/phones"><i class="bi bi-telephone"></i> Телефоны</a>
<a class="nav-link" href="/broadcast"><i class="bi bi-broadcast"></i> Рассылки</a>
<a class="nav-link" href="/polls"><i class="bi bi-bar-chart"></i> Опросы</a>
<a class="nav-link" href="/events"><i class="bi bi-calendar-event"></i> События</a>
<a class="nav-link" href="/schedules"><i class="bi bi-clock"></i> Расписания</a>
<a class="nav-link active" href="/settings"><i class="bi bi-gear"></i> Настройки</a>
<a class="nav-link" href="/export"><i class="bi bi-download"></i> Экспорт</a>
</nav>
</div>
<div class="col-md-10 content">
<h2 class="mb-4"><i class="bi bi-gear"></i> Настройки бота</h2>
<!-- IG Broadcast Limit -->
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-people"></i> Рассылка Инициативной Группы</h5>
</div>
<div class="card-body">
<form id="igBroadcastForm">
<div class="mb-3">
<label class="form-label">Минимальный интервал между рассылками (секунды)</label>
<input type="number" class="form-control" id="igBroadcastLimit" value="{{ settings.ig_broadcast_limit }}" min="0" step="60">
<small class="text-muted">
<ul class="mb-0">
<li><code>0</code> — Без ограничений (можно спамить!)</li>
<li><code>3600</code> — 1 раз в час</li>
<li><code>86400</code> — 1 раз в сутки</li>
</ul>
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
</form>
<div id="igBroadcastResult" class="mt-3"></div>
</div>
</div>
<!-- Info -->
<div class="card">
<div class="card-body">
<h5> Информация</h5>
<p>Здесь будут настройки бота для суперадмина.</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.getElementById('igBroadcastForm').addEventListener('submit', async (e) => {
e.preventDefault();
const limit = parseInt(document.getElementById('igBroadcastLimit').value);
const resultDiv = document.getElementById('igBroadcastResult');
resultDiv.innerHTML = '<div class="alert alert-info">💾 Сохранение...</div>';
try {
const r = await fetch('/api/settings/ig_broadcast_limit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: limit })
});
const result = await r.json();
if (result.success) {
resultDiv.innerHTML = '<div class="alert alert-success">✅ Настройка сохранена!</div>';
} else {
resultDiv.innerHTML = '<div class="alert alert-danger">❌ Ошибка: ' + result.error + '</div>';
}
} catch (err) {
resultDiv.innerHTML = '<div class="alert alert-danger">❌ Ошибка: ' + err.message + '</div>';
}
});
</script>
</body>
</html>

View file

@ -42,6 +42,8 @@
✅ Верифицированные
{% elif current_filter == 'unverified' %}
Не верифицированы
{% elif current_filter == 'ig' %}
🔵 Инициативная группа
{% elif current_filter == 'active_unverified' %}
🔥 Активные (50+ сообщ.)
{% else %}
@ -78,11 +80,11 @@
</a>
</div>
<div class="col-md-3">
<a href="/users?filter=unverified" style="text-decoration: none;">
<div class="card bg-warning text-dark">
<a href="/users?filter=ig" style="text-decoration: none;">
<div class="card text-white" style="background: linear-gradient(135deg, #17a2b8, #117a8b);">
<div class="card-body text-center">
<h3>{{ stats.unverified }}</h3>
<small>Не верифицировано</small>
<h3>{{ stats.ig }}</h3>
<small>Инициативная группа</small>
</div>
</div>
</a>