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:
parent
cef2ac3714
commit
ff03e38ee7
5 changed files with 163 additions and 142 deletions
112
web/app.py
112
web/app.py
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue