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)
|
stmt = stmt.where(User.verified == False)
|
||||||
elif filter == 'active_unverified':
|
elif filter == 'active_unverified':
|
||||||
stmt = stmt.where(User.verified == False, User.message_count >= 50)
|
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' или пустой — показываем всех
|
# filter == 'all' или пустой — показываем всех
|
||||||
|
|
||||||
# Поиск
|
# Поиск
|
||||||
|
|
@ -114,10 +121,11 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
|
||||||
active_unverified = (await session.execute(
|
active_unverified = (await session.execute(
|
||||||
select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50)
|
select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50)
|
||||||
)).scalar()
|
)).scalar()
|
||||||
|
ig_count = len(ig_user_ids)
|
||||||
|
|
||||||
return templates.TemplateResponse("users.html",
|
return templates.TemplateResponse("users.html",
|
||||||
{"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
|
{"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})
|
"ig_user_ids": ig_user_ids})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -592,6 +600,22 @@ async def api_send_broadcast(
|
||||||
chat_sent = True
|
chat_sent = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Ошибка отправки в чат: {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({
|
return JSONResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -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)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
# Настройка лимита рассылки ИГ теперь в /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
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/settings/ig_broadcast_limit")
|
@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} сек"})
|
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)):
|
@app.get("/api/settings/ig_broadcast_limit")
|
||||||
"""Настройки"""
|
async def api_get_ig_broadcast_limit(
|
||||||
return templates.TemplateResponse("settings.html", {
|
username: str = Depends(get_current_admin)
|
||||||
"request": request, "username": username,
|
):
|
||||||
"config": {
|
"""Получить лимит рассылки ИГ"""
|
||||||
"bot_token": config.BOT_TOKEN[:20] + "...",
|
from sqlalchemy import text
|
||||||
"admin_chat_id": config.ADMIN_CHAT_ID,
|
|
||||||
"admin_user_id": config.ADMIN_USER_ID,
|
async with AsyncSessionLocal() as session:
|
||||||
"use_proxy": config.USE_PROXY,
|
result = await session.execute(text('''
|
||||||
"web_login": config.WEB_ADMIN_LOGIN
|
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):
|
def run_web_server(host: str = "0.0.0.0", port: int = 8000):
|
||||||
|
|
|
||||||
|
|
@ -91,17 +91,85 @@
|
||||||
<ol>
|
<ol>
|
||||||
<li>Введите текст объявления</li>
|
<li>Введите текст объявления</li>
|
||||||
<li>Прикрепите картинку (необязательно)</li>
|
<li>Прикрепите картинку (необязательно)</li>
|
||||||
|
<li>Выберите получателей</li>
|
||||||
<li>Нажмите "Отправить рассылку"</li>
|
<li>Нажмите "Отправить рассылку"</li>
|
||||||
<li>Бот отправит всем верифицированным пользователям в личку</li>
|
<li>Бот отправит выбранным получателям</li>
|
||||||
<li>Также отправит в общий чат</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<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() {
|
document.getElementById('broadcastRecipients').addEventListener('change', function() {
|
||||||
const info = {
|
const info = {
|
||||||
|
|
|
||||||
|
|
@ -138,10 +138,10 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<a href="/users?filter=unverified" style="text-decoration: none;">
|
<a href="/users?filter=ig" style="text-decoration: none;">
|
||||||
<div class="stat-card warning">
|
<div class="stat-card" style="background: linear-gradient(135deg, #17a2b8, #117a8b);">
|
||||||
<div class="stat-value">{{ stats.unverified }}</div>
|
<div class="stat-value">{{ stats.ig }}</div>
|
||||||
<div class="stat-label">Не верифицировано</div>
|
<div class="stat-label">Инициативная группа</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 == 'unverified' %}
|
||||||
⏳ Не верифицированы
|
⏳ Не верифицированы
|
||||||
|
{% elif current_filter == 'ig' %}
|
||||||
|
🔵 Инициативная группа
|
||||||
{% elif current_filter == 'active_unverified' %}
|
{% elif current_filter == 'active_unverified' %}
|
||||||
🔥 Активные (50+ сообщ.)
|
🔥 Активные (50+ сообщ.)
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
@ -78,11 +80,11 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<a href="/users?filter=unverified" style="text-decoration: none;">
|
<a href="/users?filter=ig" style="text-decoration: none;">
|
||||||
<div class="card bg-warning text-dark">
|
<div class="card text-white" style="background: linear-gradient(135deg, #17a2b8, #117a8b);">
|
||||||
<div class="card-body text-center">
|
<div class="card-body text-center">
|
||||||
<h3>{{ stats.unverified }}</h3>
|
<h3>{{ stats.ig }}</h3>
|
||||||
<small>Не верифицировано</small>
|
<small>Инициативная группа</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue