✅ Проведено полное тестирование функционала: - Меню жильца (11 кнопок) - Меню админа (17 кнопок) - База данных (проверка записей) - Уникальность callback_data - Сообщения в личку (без спама) 📝 Созданные файлы: - tests/run_tests.py — автотесты - tests/TEST_REPORT.md — отчёт о тестировании - tests/COMPREHENSIVE_TEST_PLAN.md — план тестов - send_test_report.py — отправка отчёта 📊 Результаты: - Все кнопки работают - Участковский работает через БД (2 записи) - Сообщения только в личку - Веб-админка работает со всеми разделами 🎯 ВСЁ РАБОТАЕТ! Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
524 lines
18 KiB
Python
524 lines
18 KiB
Python
"""
|
||
Web Admin Interface - Full Functional Admin Panel
|
||
Веб-интерфейс с полным функционалом управления ботом
|
||
"""
|
||
import logging
|
||
import json
|
||
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, Query
|
||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, update, delete
|
||
from sqlalchemy.orm import Session
|
||
from datetime import datetime, timedelta
|
||
import secrets
|
||
import config
|
||
import aiohttp
|
||
|
||
from database.db import AsyncSessionLocal, get_db
|
||
from database.models import (
|
||
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
||
Achievement, Thank, Event, EventParticipant, InitiativeGroup,
|
||
ProfileHistory, Announcement, Service, VerificationRequest, SpyLog
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Приложение
|
||
app = FastAPI(
|
||
title="Домовой Бот - Полная Админ Панель",
|
||
description="Веб-интерфейс с полным функционалом",
|
||
version="2.1.3"
|
||
)
|
||
|
||
# Шаблоны
|
||
templates = Jinja2Templates(directory="web/templates")
|
||
|
||
# Статика
|
||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||
|
||
# Безопасность
|
||
security = HTTPBasic()
|
||
|
||
|
||
# =============================================================================
|
||
# АВТОРИЗАЦИЯ
|
||
# =============================================================================
|
||
|
||
def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
|
||
"""Проверка админа"""
|
||
correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN)
|
||
correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD)
|
||
|
||
if not (correct_username and correct_password):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="Неверное имя пользователя или пароль",
|
||
headers={"WWW-Authenticate": "Basic"},
|
||
)
|
||
|
||
return credentials.username
|
||
|
||
|
||
# =============================================================================
|
||
# ГЛАВНАЯ СТРАНИЦА
|
||
# =============================================================================
|
||
|
||
@app.get("/", response_class=HTMLResponse)
|
||
async def dashboard(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Главная страница - дашборд"""
|
||
async with AsyncSessionLocal() as session:
|
||
users_count = await session.execute(select(func.count(User.user_id)))
|
||
users_count = users_count.scalar()
|
||
|
||
messages_count = await session.execute(select(func.count(Message.id)))
|
||
messages_count = messages_count.scalar()
|
||
|
||
verified_count = await session.execute(
|
||
select(func.count(User.user_id)).where(User.verified == True)
|
||
)
|
||
verified_count = verified_count.scalar()
|
||
|
||
ig_count = await session.execute(
|
||
select(func.count(InitiativeGroup.id)).where(InitiativeGroup.is_active == True)
|
||
)
|
||
ig_count = ig_count.scalar()
|
||
|
||
return templates.TemplateResponse("dashboard.html", {
|
||
"request": request,
|
||
"username": username,
|
||
"stats": {
|
||
"users": users_count,
|
||
"messages": messages_count,
|
||
"verified": verified_count,
|
||
"ig_members": ig_count
|
||
}
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# ПОЛЬЗОВАТЕЛИ - API
|
||
# =============================================================================
|
||
|
||
@app.get("/api/users")
|
||
async def api_get_users(
|
||
username: str = Depends(get_current_admin),
|
||
search: str = Query(None),
|
||
apartment: str = Query(None),
|
||
verified: bool = Query(None),
|
||
limit: int = Query(100)
|
||
):
|
||
"""Получить список пользователей с фильтрами"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(User)
|
||
|
||
if search:
|
||
stmt = stmt.where(
|
||
(User.full_name.ilike(f"%{search}%")) |
|
||
(User.username.ilike(f"%{search}%"))
|
||
)
|
||
|
||
if apartment:
|
||
stmt = stmt.where(User.apartment.ilike(f"%{apartment}%"))
|
||
|
||
if verified is not None:
|
||
stmt = stmt.where(User.verified == verified)
|
||
|
||
stmt = stmt.order_by(User.user_id.desc()).limit(limit)
|
||
result = await session.execute(stmt)
|
||
users = list(result.scalars().all())
|
||
|
||
return {
|
||
"users": [
|
||
{
|
||
"user_id": u.user_id,
|
||
"full_name": u.full_name,
|
||
"username": u.username,
|
||
"apartment": u.apartment,
|
||
"verified": u.verified,
|
||
"rating": u.rating,
|
||
"message_count": u.message_count,
|
||
"is_admin": u.is_admin,
|
||
"spy_score": u.spy_score,
|
||
"toxicity_score": u.toxicity_score
|
||
}
|
||
for u in users
|
||
]
|
||
}
|
||
|
||
|
||
@app.post("/api/users/{user_id}/verify")
|
||
async def api_verify_user(
|
||
user_id: int,
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Верифицировать пользователя"""
|
||
async with AsyncSessionLocal() as session:
|
||
user = await session.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
|
||
user.verified = True
|
||
user.verification_date = datetime.utcnow()
|
||
await session.commit()
|
||
|
||
# Уведомить пользователя
|
||
try:
|
||
async with aiohttp.ClientSession() as http_session:
|
||
await http_session.post(
|
||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||
json={
|
||
"chat_id": user_id,
|
||
"text": f"✅ <b>Верификация подтверждена!</b>\n\nТеперь у вас есть доступ ко всем функциям бота.",
|
||
"parse_mode": "HTML"
|
||
}
|
||
)
|
||
except:
|
||
pass
|
||
|
||
return {"success": True, "message": f"Пользователь {user_id} верифицирован"}
|
||
|
||
|
||
@app.post("/api/users/{user_id}/unverify")
|
||
async def api_unverify_user(
|
||
user_id: int,
|
||
reason: str = Form(None),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Снять верификацию с пользователя"""
|
||
async with AsyncSessionLocal() as session:
|
||
user = await session.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
|
||
user.verified = False
|
||
await session.commit()
|
||
|
||
# Уведомить пользователя
|
||
try:
|
||
async with aiohttp.ClientSession() as http_session:
|
||
await http_session.post(
|
||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||
json={
|
||
"chat_id": user_id,
|
||
"text": f"⚠️ <b>Верификация отозвана</b>\n\nПричина: {reason or 'Без указания причины'}\n\nУ вас есть 7 дней на повторную верификацию.",
|
||
"parse_mode": "HTML"
|
||
}
|
||
)
|
||
except:
|
||
pass
|
||
|
||
return {"success": True, "message": f"Верификация пользователя {user_id} отозвана"}
|
||
|
||
|
||
@app.post("/api/users/{user_id}/set_admin")
|
||
async def api_set_admin(
|
||
user_id: int,
|
||
is_admin: bool = Form(True),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Назначить/снять права админа"""
|
||
async with AsyncSessionLocal() as session:
|
||
user = await session.get(User, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||
|
||
user.is_admin = is_admin
|
||
await session.commit()
|
||
|
||
return {"success": True, "message": f"Пользователь {user_id} {'назначен' if is_admin else 'снят с должности'} админа"}
|
||
|
||
|
||
@app.get("/api/initiative_group")
|
||
async def api_get_ig_members(username: str = Depends(get_current_admin)):
|
||
"""Получить список участников ИГ"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(InitiativeGroup).where(InitiativeGroup.is_active == True)
|
||
result = await session.execute(stmt)
|
||
members = list(result.scalars().all())
|
||
|
||
return {
|
||
"members": [
|
||
{
|
||
"user_id": m.user_id,
|
||
"role": m.role,
|
||
"joined_at": m.joined_at.isoformat(),
|
||
"name": m.user.full_name if m.user else f"user{m.user_id}",
|
||
"apartment": m.user.apartment if m.user else None
|
||
}
|
||
for m in members
|
||
]
|
||
}
|
||
|
||
|
||
@app.post("/api/initiative_group/add")
|
||
async def api_add_ig_member(
|
||
user_id: int = Form(...),
|
||
role: str = Form("member"),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Добавить в ИГ"""
|
||
async with AsyncSessionLocal() as session:
|
||
# Проверяем есть ли уже
|
||
existing = await session.get(InitiativeGroup, user_id)
|
||
if existing:
|
||
existing.is_active = True
|
||
existing.role = role
|
||
else:
|
||
member = InitiativeGroup(user_id=user_id, role=role)
|
||
session.add(member)
|
||
|
||
await session.commit()
|
||
|
||
return {"success": True, "message": f"Пользователь {user_id} добавлен в ИГ"}
|
||
|
||
|
||
@app.post("/api/initiative_group/remove")
|
||
async def api_remove_ig_member(
|
||
user_id: int = Form(...),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Удалить из ИГ"""
|
||
async with AsyncSessionLocal() as session:
|
||
member = await session.get(InitiativeGroup, user_id)
|
||
if member:
|
||
member.is_active = False
|
||
await session.commit()
|
||
|
||
return {"success": True, "message": f"Пользователь {user_id} удалён из ИГ"}
|
||
|
||
|
||
# =============================================================================
|
||
# ТЕЛЕФОНЫ - API
|
||
# =============================================================================
|
||
|
||
@app.get("/api/phones")
|
||
async def api_get_phones(username: str = Depends(get_current_admin)):
|
||
"""Получить список телефонов"""
|
||
import os
|
||
phones_file = "data/phones.json"
|
||
|
||
if os.path.exists(phones_file):
|
||
with open(phones_file, 'r', encoding='utf-8') as f:
|
||
phones_data = json.load(f)
|
||
return {"success": True, "data": phones_data}
|
||
else:
|
||
return {"success": False, "data": {}}
|
||
|
||
|
||
@app.post("/api/phones/update")
|
||
async def api_update_phones(
|
||
request: Request,
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Обновить телефоны"""
|
||
import os
|
||
data = await request.json()
|
||
phones_file = "data/phones.json"
|
||
|
||
try:
|
||
with open(phones_file, 'w', encoding='utf-8') as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
return {"success": True, "message": "Телефоны обновлены"}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e)}
|
||
|
||
|
||
# =============================================================================
|
||
# ОБЪЯВЛЕНИЯ - API
|
||
# =============================================================================
|
||
|
||
@app.get("/api/announcements")
|
||
async def api_get_announcements(username: str = Depends(get_current_admin)):
|
||
"""Получить список объявлений"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(Announcement).order_by(Announcement.created_at.desc())
|
||
result = await session.execute(stmt)
|
||
announcements = list(result.scalars().all())
|
||
|
||
return {
|
||
"announcements": [
|
||
{
|
||
"id": a.id,
|
||
"title": a.title,
|
||
"text": a.text,
|
||
"category": a.category,
|
||
"is_active": a.is_active,
|
||
"created_at": a.created_at.isoformat()
|
||
}
|
||
for a in announcements
|
||
]
|
||
}
|
||
|
||
|
||
@app.post("/api/announcements/create")
|
||
async def api_create_announcement(
|
||
title: str = Form(...),
|
||
text: str = Form(...),
|
||
category: str = Form("general"),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Создать объявление"""
|
||
async with AsyncSessionLocal() as session:
|
||
announcement = Announcement(
|
||
title=title,
|
||
text=text,
|
||
category=category,
|
||
created_by=username
|
||
)
|
||
session.add(announcement)
|
||
await session.commit()
|
||
|
||
return {"success": True, "message": "Объявление создано"}
|
||
|
||
|
||
# =============================================================================
|
||
# ВЫПОЛНЕНИЕ КОМАНД БОТА
|
||
# =============================================================================
|
||
|
||
@app.post("/api/command")
|
||
async def api_command(
|
||
request: Request,
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Выполнить команду бота"""
|
||
data = await request.json()
|
||
command = data.get('command', '')
|
||
args = data.get('args', '')
|
||
|
||
if not command:
|
||
return JSONResponse({"success": False, "error": "Нет команды"})
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as http_session:
|
||
full_command = f"/{command}"
|
||
if args:
|
||
full_command += f" {args}"
|
||
|
||
result = await http_session.post(
|
||
f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage",
|
||
json={
|
||
"chat_id": config.ADMIN_CHAT_ID,
|
||
"text": full_command
|
||
}
|
||
)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Команда /{command} отправлена"
|
||
})
|
||
except Exception as e:
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": str(e)
|
||
}, status_code=500)
|
||
|
||
|
||
@app.get("/api/commands")
|
||
async def api_commands_list(username: str = Depends(get_current_admin)):
|
||
"""Список доступных команд"""
|
||
commands = {
|
||
"admin": [
|
||
{"cmd": "admin", "desc": "Админ-панель"},
|
||
{"cmd": "stats", "desc": "Статистика чата"},
|
||
{"cmd": "spies", "desc": "Подозрительные пользователи"},
|
||
{"cmd": "users", "desc": "Список жильцов"},
|
||
{"cmd": "verify", "desc": "Заявки на верификацию"},
|
||
{"cmd": "export", "desc": "Экспорт данных"},
|
||
{"cmd": "rating", "desc": "Рейтинг пользователей"},
|
||
],
|
||
"initiative_group": [
|
||
{"cmd": "ig_add", "desc": "Добавить в ИГ"},
|
||
{"cmd": "ig_remove", "desc": "Удалить из ИГ"},
|
||
{"cmd": "ig_list", "desc": "Список ИГ"},
|
||
{"cmd": "ig_stats", "desc": "Статистика ИГ"},
|
||
{"cmd": "ig_broadcast", "desc": "Рассылка для ИГ"},
|
||
],
|
||
"events": [
|
||
{"cmd": "event_add", "desc": "Добавить событие"},
|
||
{"cmd": "events", "desc": "Список событий"},
|
||
{"cmd": "participants", "desc": "Участники события"},
|
||
],
|
||
"security": [
|
||
{"cmd": "unverify", "desc": "Отозвать верификацию"},
|
||
{"cmd": "unverified_list", "desc": "Список не верифицированных"},
|
||
{"cmd": "ban", "desc": "Забанить пользователя"},
|
||
{"cmd": "unban", "desc": "Разбанить"},
|
||
],
|
||
"phones": [
|
||
{"cmd": "add_phone", "desc": "Добавить телефон"},
|
||
{"cmd": "edit_phone", "desc": "Редактировать телефон"},
|
||
{"cmd": "remove_phone", "desc": "Удалить телефон"},
|
||
]
|
||
}
|
||
return JSONResponse({"commands": commands})
|
||
|
||
|
||
# =============================================================================
|
||
# СТРАНИЦЫ
|
||
# =============================================================================
|
||
|
||
@app.get("/users", response_class=HTMLResponse)
|
||
async def users_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница пользователей"""
|
||
return templates.TemplateResponse("users.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
@app.get("/initiative_group", response_class=HTMLResponse)
|
||
async def ig_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница ИГ"""
|
||
return templates.TemplateResponse("initiative_group.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
@app.get("/phones", response_class=HTMLResponse)
|
||
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница телефонов"""
|
||
return templates.TemplateResponse("phones.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
@app.get("/announcements", response_class=HTMLResponse)
|
||
async def announcements_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница объявлений"""
|
||
return templates.TemplateResponse("announcements.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
@app.get("/commands", response_class=HTMLResponse)
|
||
async def commands_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница команд"""
|
||
return templates.TemplateResponse("commands.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
# =============================================================================
|
||
# ЗАПУСК
|
||
# =============================================================================
|
||
|
||
def run_web_server():
|
||
"""Запустить веб-сервер"""
|
||
import uvicorn
|
||
uvicorn.run(
|
||
app,
|
||
host=config.WEB_ADMIN_HOST,
|
||
port=config.WEB_ADMIN_PORT,
|
||
log_level="info"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_web_server()
|