- Используется aiohttp вместо bot.send_message - Нет ошибки Timeout context manager - Коммит в Gitea Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1486 lines
62 KiB
Python
1486 lines
62 KiB
Python
"""
|
||
Web Admin Interface v3.0 - MEGA ADMIN PANEL
|
||
Полноценное управление ВСЕМ функционалом бота
|
||
"""
|
||
import logging
|
||
import json
|
||
import aiohttp
|
||
from pathlib import Path
|
||
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, File, UploadFile
|
||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, update, delete, desc
|
||
from datetime import datetime, timedelta, timezone
|
||
import secrets
|
||
import config
|
||
|
||
# ===== ЧАСОВОЙ ПОЯС (Ульяновск, UTC+4) =====
|
||
LOCAL_TZ = timezone(timedelta(hours=4)) # Самара/Ульяновск
|
||
|
||
def to_local(dt: datetime) -> datetime:
|
||
"""Конвертировать UTC datetime в локальное время (Ульяновск, UTC+4)"""
|
||
if dt is None:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
# Считаем что naive datetime — это UTC
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt.astimezone(LOCAL_TZ)
|
||
|
||
def fmt_local(dt: datetime, fmt: str = '%d.%m.%Y %H:%M') -> str:
|
||
"""Форматировать UTC datetime в строку локального времени"""
|
||
local_dt = to_local(dt)
|
||
return local_dt.strftime(fmt) if local_dt else '-'
|
||
|
||
from database.db import AsyncSessionLocal
|
||
from database.models import (
|
||
User, Message, Poll, Ad, PaymentReminder, Schedule,
|
||
VerificationRequest, Service, Announcement, Event, ScheduledPost,
|
||
Broadcast, BroadcastRead, Digest
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
app = FastAPI(title="Домовой Бот - MEGA Admin Panel", version="3.0.0")
|
||
|
||
# Добавляем CORS middleware
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"], # Разрешаем все origins (для локального использования)
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
templates = Jinja2Templates(directory="web/templates")
|
||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||
app.mount("/static/service_images", StaticFiles(directory="data/service_images"), name="service_images")
|
||
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=401, 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)))).scalar()
|
||
verified_count = (await session.execute(
|
||
select(func.count(User.user_id)).where(User.verified == True))).scalar()
|
||
messages_count = (await session.execute(select(func.count(Message.id)))).scalar()
|
||
active_ads = (await session.execute(
|
||
select(func.count(Ad.ad_id)).where(Ad.is_active == True))).scalar()
|
||
active_polls = (await session.execute(
|
||
select(func.count(Poll.poll_id)).where(Poll.is_active == True))).scalar()
|
||
upcoming_events = (await session.execute(
|
||
select(func.count(Event.id)).where(Event.is_active == True))).scalar()
|
||
|
||
# Получаем количество ожидающих верификации
|
||
from database.models import VerificationRequest
|
||
pending_count = (await session.execute(
|
||
select(func.count(VerificationRequest.id)).where(VerificationRequest.status == 'pending')
|
||
)).scalar()
|
||
|
||
return templates.TemplateResponse("dashboard.html", {
|
||
"request": request, "username": username,
|
||
"stats": {
|
||
"users": users_count, "verified": verified_count,
|
||
"messages": messages_count, "ads": active_ads,
|
||
"polls": active_polls, "events": upcoming_events
|
||
},
|
||
"pending_count": pending_count
|
||
})
|
||
|
||
|
||
# ============================================================================
|
||
# ПОЛЬЗОВАТЕЛИ
|
||
# ============================================================================
|
||
|
||
@app.get("/users", response_class=HTMLResponse)
|
||
async def users_page(request: Request, search: str = "", filter: str = "", username: str = Depends(get_current_admin)):
|
||
"""Пользователи с фильтрами"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(User)
|
||
|
||
# Применяем фильтр
|
||
if filter == 'verified':
|
||
stmt = stmt.where(User.verified == True)
|
||
elif filter == 'unverified':
|
||
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' или пустой — показываем всех
|
||
|
||
# Поиск
|
||
if search:
|
||
stmt = stmt.where(
|
||
(User.first_name.ilike(f"%{search}%")) |
|
||
(User.last_name.ilike(f"%{search}%")) |
|
||
(User.username.ilike(f"%{search}%")) |
|
||
(User.apartment.ilike(f"%{search}%"))
|
||
)
|
||
|
||
stmt = stmt.order_by(User.user_id.desc()).limit(100)
|
||
users = list((await session.execute(stmt)).scalars().all())
|
||
|
||
# Получаем список ИГ
|
||
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 = set(row[0] for row in ig_result.all())
|
||
|
||
# Получаем заявки на верификацию
|
||
from database.models import VerificationRequest
|
||
pending_stmt = select(VerificationRequest).where(
|
||
VerificationRequest.status == 'pending'
|
||
).order_by(VerificationRequest.created_at.desc())
|
||
pending_result = await session.execute(pending_stmt)
|
||
pending_requests = [
|
||
{
|
||
'id': req.id,
|
||
'user_id': req.user_id,
|
||
'user_name': req.user.full_name if req.user else None,
|
||
'username': req.user.username if req.user else None,
|
||
'apartment': req.apartment,
|
||
'created_at': req.created_at
|
||
}
|
||
for req in pending_result.scalars().all()
|
||
]
|
||
|
||
# Статистика
|
||
total_count = (await session.execute(select(func.count(User.user_id)))).scalar()
|
||
verified_count = (await session.execute(select(func.count(User.user_id)).where(User.verified == True))).scalar()
|
||
unverified_count = total_count - verified_count
|
||
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, "ig": ig_count},
|
||
"ig_user_ids": ig_user_ids,
|
||
"pending_requests": pending_requests,
|
||
"pending_count": len(pending_requests)})
|
||
|
||
|
||
@app.post("/api/users/auto_verify")
|
||
async def api_auto_verify_users(username: str = Depends(get_current_admin)):
|
||
"""Автоверификация активных пользователей (ТОЛЬКО с квартирой!)"""
|
||
async with AsyncSessionLocal() as session:
|
||
# Находим активных (50+ сообщений) которые не верифицированы
|
||
# И у которых УЖЕ ЕСТЬ квартира!
|
||
stmt = select(User).where(
|
||
User.verified == False,
|
||
User.message_count >= 50,
|
||
User.apartment != None,
|
||
User.apartment != ''
|
||
)
|
||
result = await session.execute(stmt)
|
||
active_users = list(result.scalars().all())
|
||
|
||
# Верифицируем всех
|
||
verified_count = 0
|
||
for user in active_users:
|
||
user.verified = True
|
||
if not user.verification_date:
|
||
user.verification_date = datetime.utcnow()
|
||
verified_count += 1
|
||
|
||
await session.commit()
|
||
|
||
return JSONResponse({"success": True, "message": f"Верифицировано {verified_count} активных пользователей с квартирой"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/update")
|
||
async def api_update_user(user_id: int, request: Request,
|
||
username: str = Depends(get_current_admin)):
|
||
"""Обновить пользователя"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
update_data = {}
|
||
if 'apartment' in data: update_data['apartment'] = data['apartment'].upper()
|
||
if 'phone' in data: update_data['phone'] = data['phone']
|
||
if 'verified' in data:
|
||
update_data['verified'] = bool(data['verified'])
|
||
if data['verified']: update_data['verification_date'] = datetime.utcnow()
|
||
if 'banned' in data: update_data['is_banned'] = bool(data['banned'])
|
||
if 'rating' in data: update_data['rating'] = int(data['rating'])
|
||
if update_data:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(**update_data))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
@app.get("/api/user/{user_id}")
|
||
async def api_get_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:
|
||
return JSONResponse({"error": "Пользователь не найден"}, status_code=404)
|
||
|
||
return JSONResponse({
|
||
"user": {
|
||
"id": user.user_id,
|
||
"username": user.username,
|
||
"first_name": user.first_name,
|
||
"last_name": user.last_name,
|
||
"apartment": user.apartment,
|
||
"phone": user.phone,
|
||
"verified": user.verified,
|
||
"rating": user.rating,
|
||
"message_count": user.message_count,
|
||
"is_banned": user.is_banned,
|
||
}
|
||
})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/ban")
|
||
async def api_ban_user(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Забанить"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(is_banned=True))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Забанен"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/unban")
|
||
async def api_unban_user(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Разбанить"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(is_banned=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Разбанен"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/verify")
|
||
async def api_verify_user(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Верифицировать"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(
|
||
verified=True, verification_date=datetime.utcnow()))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Верифицирован"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/unverify")
|
||
async def api_unverify_user(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Отозвать верификацию"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(verified=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Верификация отозвана"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/make_admin")
|
||
async def api_make_admin(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Назначить админом"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(is_admin=True))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Пользователь теперь админ"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/remove_admin")
|
||
async def api_remove_admin(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Снять админа"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(User).where(User.user_id == user_id).values(is_admin=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Админ снят"})
|
||
|
||
|
||
@app.post("/api/user/{user_id}/add_to_ig")
|
||
async def api_add_to_ig(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Добавить в Инициативную Группу"""
|
||
from services.initiative_group import InitiativeGroupService
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
success = await ig_service.add_member(user_id, 'member')
|
||
|
||
if success:
|
||
return JSONResponse({"success": True, "message": "Добавлен в ИГ"})
|
||
else:
|
||
return JSONResponse({"error": "Не удалось добавить в ИГ"}, status_code=400)
|
||
|
||
|
||
@app.post("/api/user/{user_id}/remove_from_ig")
|
||
async def api_remove_from_ig(user_id: int, username: str = Depends(get_current_admin)):
|
||
"""Удалить из Инициативной Группы"""
|
||
from services.initiative_group import InitiativeGroupService
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
ig_service = InitiativeGroupService(session)
|
||
success = await ig_service.remove_member(user_id)
|
||
|
||
if success:
|
||
return JSONResponse({"success": True, "message": "Удалён из ИГ"})
|
||
else:
|
||
return JSONResponse({"error": "Не найден в ИГ"}, status_code=400)
|
||
|
||
|
||
# ============================================================================
|
||
# ВЕРИФИКАЦИЯ
|
||
# ============================================================================
|
||
|
||
@app.get("/verification", response_class=HTMLResponse)
|
||
async def verification_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Верификация"""
|
||
async with AsyncSessionLocal() as session:
|
||
pending = list((await session.execute(
|
||
select(VerificationRequest).where(VerificationRequest.status == 'pending')
|
||
.order_by(VerificationRequest.created_at.desc()).limit(50)
|
||
)).scalars().all())
|
||
unverified = list((await session.execute(
|
||
select(User).where(User.verified == False).order_by(User.join_date.desc()).limit(50)
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("verification.html", {
|
||
"request": request, "username": username,
|
||
"pending_requests": pending, "unverified_users": unverified
|
||
})
|
||
|
||
|
||
@app.post("/api/verification/{req_id}/approve")
|
||
async def api_approve_verification(req_id: int, username: str = Depends(get_current_admin)):
|
||
"""Одобрить верификацию"""
|
||
async with AsyncSessionLocal() as session:
|
||
req = (await session.execute(select(VerificationRequest).where(
|
||
VerificationRequest.id == req_id))).scalar_one_or_none()
|
||
if req:
|
||
req.status = 'approved'
|
||
req.reviewed_at = datetime.utcnow()
|
||
await session.execute(update(User).where(User.user_id == req.user_id).values(
|
||
verified=True, apartment=req.apartment, verification_date=datetime.utcnow()))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
@app.post("/api/verification/{req_id}/reject")
|
||
async def api_reject_verification(req_id: int, username: str = Depends(get_current_admin)):
|
||
"""Отклонить верификацию"""
|
||
async with AsyncSessionLocal() as session:
|
||
req = (await session.execute(select(VerificationRequest).where(
|
||
VerificationRequest.id == req_id))).scalar_one_or_none()
|
||
if req:
|
||
req.status = 'rejected'
|
||
req.reviewed_at = datetime.utcnow()
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
# ============================================================================
|
||
# ТЕЛЕФОНЫ И СЛУЖБЫ
|
||
# ============================================================================
|
||
|
||
@app.get("/phones", response_class=HTMLResponse)
|
||
async def phones_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Телефоны и службы"""
|
||
async with AsyncSessionLocal() as session:
|
||
services = list((await session.execute(select(Service).order_by(Service.category, Service.name)
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("phones.html", {
|
||
"request": request, "username": username, "services": services
|
||
})
|
||
|
||
|
||
@app.post("/api/service/add")
|
||
async def api_add_service(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Добавить службу"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
service = Service(
|
||
name=data['name'],
|
||
phone=data['phone'],
|
||
category=data.get('category', 'other'),
|
||
description=data.get('description', ''),
|
||
verified_by_admin=True,
|
||
image_path=data.get('image_path', None)
|
||
)
|
||
session.add(service)
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Служба добавлена"})
|
||
|
||
|
||
@app.post("/api/service/upload_image")
|
||
async def api_upload_service_image(
|
||
file: UploadFile = File(...),
|
||
category: str = Form(...),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Загрузить картинку для службы"""
|
||
import os
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
# Создаём папку для картинок
|
||
images_dir = Path("data/service_images")
|
||
images_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Генерируем уникальное имя файла
|
||
file_extension = file.filename.split(".")[-1] if "." in file.filename else "jpg"
|
||
unique_filename = f"{category}_{uuid.uuid4().hex}.{file_extension}"
|
||
file_path = images_dir / unique_filename
|
||
|
||
# Сохраняем файл
|
||
try:
|
||
with open(file_path, "wb") as f:
|
||
content = await file.read()
|
||
f.write(content)
|
||
|
||
# Обновляем все службы этой категории
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(
|
||
update(Service)
|
||
.where(Service.category == category)
|
||
.values(image_path=str(file_path))
|
||
)
|
||
await session.commit()
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Картинка загружена",
|
||
"file_path": str(file_path)
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Ошибка загрузки картинки: {e}")
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": str(e)
|
||
}, status_code=500)
|
||
|
||
|
||
@app.post("/api/service/{service_id}/update")
|
||
async def api_update_service(service_id: int, request: Request,
|
||
username: str = Depends(get_current_admin)):
|
||
"""Обновить службу"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(Service).where(Service.id == service_id).values(**data))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
@app.post("/api/service/{service_id}/delete")
|
||
async def api_delete_service(service_id: int, username: str = Depends(get_current_admin)):
|
||
"""Удалить службу"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(delete(Service).where(Service.service_id == service_id))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
# ============================================================================
|
||
# ОБЪЯВЛЕНИЯ
|
||
# ============================================================================
|
||
|
||
@app.get("/ads", response_class=HTMLResponse)
|
||
async def ads_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Объявления"""
|
||
async with AsyncSessionLocal() as session:
|
||
ads = list((await session.execute(select(Ad).order_by(Ad.created_at.desc()).limit(100)
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("ads.html", {
|
||
"request": request, "username": username, "ads": ads
|
||
})
|
||
|
||
|
||
@app.post("/api/ad/add")
|
||
async def api_add_ad(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Добавить объявление"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
ad = Ad(
|
||
title=data['title'],
|
||
description=data.get('text', ''),
|
||
category=data.get('category', 'other'),
|
||
user_id=config.ADMIN_USER_ID,
|
||
is_active=data.get('is_active', True),
|
||
is_moderated=True
|
||
)
|
||
session.add(ad)
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Объявление добавлено"})
|
||
|
||
|
||
@app.post("/api/ad/{ad_id}/delete")
|
||
async def api_delete_ad(ad_id: int, username: str = Depends(get_current_admin)):
|
||
"""Удалить объявление"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(Ad).where(Ad.ad_id == ad_id).values(is_active=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
# ============================================================================
|
||
# РАССЫЛКИ — единая страница создания и статистики
|
||
# ============================================================================
|
||
|
||
@app.get("/broadcast", response_class=HTMLResponse)
|
||
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Рассылки — создание и статистика"""
|
||
from database.models import Broadcast, BroadcastRead
|
||
from sqlalchemy import func
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
# История рассылок
|
||
stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50)
|
||
result = await session.execute(stmt)
|
||
broadcasts = list(result.scalars().all())
|
||
|
||
# Считаем прочтения для каждой
|
||
broadcasts_data = []
|
||
for b in broadcasts:
|
||
read_count = (await session.execute(
|
||
select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id)
|
||
)).scalar() or 0
|
||
|
||
broadcasts_data.append({
|
||
'id': b.id,
|
||
'text': b.text[:100] + '...' if len(b.text) > 100 else b.text,
|
||
'sent_at': fmt_local(b.sent_at) if b.sent_at else '-',
|
||
'total_sent': b.total_sent,
|
||
'read_count': read_count,
|
||
'unread_count': b.total_sent - read_count,
|
||
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
|
||
'is_reminder_sent': b.is_reminder_sent,
|
||
'broadcast_type': b.broadcast_type or 'regular',
|
||
})
|
||
|
||
return templates.TemplateResponse("broadcast.html", {
|
||
"request": request,
|
||
"username": username,
|
||
"broadcasts": broadcasts_data,
|
||
})
|
||
|
||
|
||
# ============================================================================
|
||
# ЛОГИРОВАНИЕ АКТИВНОСТИ
|
||
# ============================================================================
|
||
|
||
@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})
|
||
|
||
|
||
# ============================================================================
|
||
# ОПРОСЫ
|
||
# ============================================================================
|
||
|
||
@app.get("/polls", response_class=HTMLResponse)
|
||
async def polls_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Опросы"""
|
||
async with AsyncSessionLocal() as session:
|
||
polls = list((await session.execute(select(Poll).order_by(Poll.created_at.desc()).limit(50)
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("polls.html", {
|
||
"request": request, "username": username, "polls": polls
|
||
})
|
||
|
||
|
||
@app.post("/api/poll/create")
|
||
async def api_create_poll(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Создать опрос"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
poll = Poll(
|
||
question=data['question'],
|
||
options=json.dumps(data['options']),
|
||
votes='{}',
|
||
created_by=config.ADMIN_USER_ID,
|
||
is_active=data.get('is_active', True),
|
||
message_id=0, # Заглушка
|
||
chat_id=config.ADMIN_CHAT_ID
|
||
)
|
||
session.add(poll)
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Опрос создан"})
|
||
|
||
|
||
@app.post("/api/poll/{poll_id}/close")
|
||
async def api_close_poll(poll_id: int, username: str = Depends(get_current_admin)):
|
||
"""Закрыть опрос"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(Poll).where(Poll.poll_id == poll_id).values(is_active=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
# ============================================================================
|
||
# СОБЫТИЯ
|
||
# ============================================================================
|
||
|
||
@app.get("/events", response_class=HTMLResponse)
|
||
async def events_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""События"""
|
||
async with AsyncSessionLocal() as session:
|
||
events = list((await session.execute(select(Event).order_by(Event.event_date.desc()).limit(50)
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("events.html", {
|
||
"request": request, "username": username, "events": events
|
||
})
|
||
|
||
|
||
@app.post("/api/event/add")
|
||
async def api_add_event(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Добавить событие"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
event = Event(
|
||
title=data['title'], description=data.get('description', ''),
|
||
event_type=data.get('event_type', 'meeting'),
|
||
event_date=datetime.fromisoformat(data['event_date']),
|
||
created_by=config.ADMIN_USER_ID,
|
||
is_active=data.get('is_active', True)
|
||
)
|
||
session.add(event)
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Событие добавлено"})
|
||
|
||
|
||
# ============================================================================
|
||
# РАСПИСАНИЯ
|
||
# ============================================================================
|
||
|
||
@app.get("/schedules", response_class=HTMLResponse)
|
||
async def schedules_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Расписания"""
|
||
async with AsyncSessionLocal() as session:
|
||
schedules = list((await session.execute(select(Schedule).order_by(Schedule.start_time.desc())
|
||
)).scalars().all())
|
||
return templates.TemplateResponse("schedules.html", {
|
||
"request": request, "username": username, "schedules": schedules
|
||
})
|
||
|
||
|
||
@app.post("/api/schedule/add")
|
||
async def api_add_schedule(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Добавить расписание"""
|
||
data = await request.json()
|
||
async with AsyncSessionLocal() as session:
|
||
schedule = Schedule(
|
||
title=data['title'], schedule_type=data.get('schedule_type', 'water'),
|
||
start_time=datetime.fromisoformat(data['start_time']),
|
||
end_time=datetime.fromisoformat(data['end_time']) if data.get('end_time') else None,
|
||
description=data.get('description', ''), is_active=data.get('is_active', True)
|
||
)
|
||
session.add(schedule)
|
||
await session.commit()
|
||
return JSONResponse({"success": True, "message": "Расписание добавлено"})
|
||
|
||
|
||
@app.post("/api/schedule/{schedule_id}/delete")
|
||
async def api_delete_schedule(schedule_id: int, username: str = Depends(get_current_admin)):
|
||
"""Удалить расписание"""
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(update(Schedule).where(
|
||
Schedule.schedule_id == schedule_id).values(is_active=False))
|
||
await session.commit()
|
||
return JSONResponse({"success": True})
|
||
|
||
|
||
# ============================================================================
|
||
# ЭКСПОРТ
|
||
# ============================================================================
|
||
|
||
@app.get("/export", response_class=HTMLResponse)
|
||
async def export_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Экспорт"""
|
||
return templates.TemplateResponse("export.html", {
|
||
"request": request, "username": username
|
||
})
|
||
|
||
|
||
@app.get("/api/export/json")
|
||
async def api_export_json(username: str = Depends(get_current_admin)):
|
||
"""Экспорт JSON"""
|
||
from services.analytics import ChatAnalytics
|
||
async with AsyncSessionLocal() as session:
|
||
analytics = ChatAnalytics(session)
|
||
filepath = await analytics.export_to_json()
|
||
return JSONResponse({"success": True, "filepath": filepath})
|
||
|
||
|
||
# ============================================================================
|
||
# ЗАПЛАНИРОВАННЫЕ ПОСТЫ
|
||
# ============================================================================
|
||
|
||
@app.get("/scheduled_posts", response_class=HTMLResponse)
|
||
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница запланированных постов"""
|
||
return templates.TemplateResponse("scheduled_posts.html", {
|
||
"request": request,
|
||
"username": username
|
||
})
|
||
|
||
|
||
@app.post("/api/scheduled_posts/create")
|
||
async def api_create_scheduled_post(
|
||
text: str = Form(...),
|
||
topic_name: str = Form(...),
|
||
recipients: str = Form("chat_only"),
|
||
scheduled_time: str = Form(...),
|
||
photo: UploadFile = File(None),
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Создать запланированный пост"""
|
||
from datetime import datetime
|
||
from config import ADMIN_CHAT_ID, get_topic_id
|
||
import aiohttp
|
||
|
||
logger.info(f"📅 СОЗДАНИЕ ЗАПЛАНИРОВАННОГО ПОСТА: тема={topic_name}, время={scheduled_time}")
|
||
|
||
try:
|
||
# Парсим время
|
||
scheduled_dt = datetime.fromisoformat(scheduled_time)
|
||
|
||
# Если есть timezone - конвертируем в UTC
|
||
if scheduled_dt.tzinfo is not None:
|
||
from datetime import timezone as tz
|
||
scheduled_dt = scheduled_dt.astimezone(tz.utc).replace(tzinfo=None)
|
||
logger.info(f"⏰ Время конвертирована в UTC: {scheduled_dt}")
|
||
|
||
# Проверяем что время в будущем (по UTC)
|
||
if scheduled_dt < datetime.utcnow():
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": "Время должно быть в будущем"
|
||
}, status_code=400)
|
||
|
||
# Получаем topic_id из topic_name
|
||
topic_id = get_topic_id(topic_name)
|
||
|
||
# Если есть фото - загружаем в Telegram
|
||
photo_file_id = None
|
||
if photo and photo.filename:
|
||
temp_path = Path("data") / f"scheduled_post_{photo.filename}"
|
||
with open(temp_path, "wb") as f:
|
||
content = await photo.read()
|
||
f.write(content)
|
||
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto"
|
||
data = aiohttp.FormData()
|
||
data.add_field('chat_id', config.ADMIN_USER_ID)
|
||
data.add_field('photo', open(temp_path, 'rb'), filename=photo.filename)
|
||
data.add_field('caption', 'Preview')
|
||
|
||
async with session.post(url, data=data) as resp:
|
||
result = await resp.json()
|
||
if result.get('ok'):
|
||
photo_file_id = result['result']['photo'][-1]['file_id']
|
||
except Exception as e:
|
||
logger.error(f"Ошибка загрузки фото: {e}")
|
||
# Не блокируем из-за фото
|
||
photo_file_id = None
|
||
|
||
# Сохраняем в БД
|
||
async with AsyncSessionLocal() as session:
|
||
scheduled_post = ScheduledPost(
|
||
text=text,
|
||
photo_file_id=photo_file_id,
|
||
topic_id=topic_id,
|
||
topic_name=topic_name,
|
||
recipients=recipients,
|
||
scheduled_time=scheduled_dt,
|
||
status='pending',
|
||
created_by=config.ADMIN_USER_ID
|
||
)
|
||
session.add(scheduled_post)
|
||
await session.commit()
|
||
await session.refresh(scheduled_post)
|
||
|
||
logger.info(f"✅ Пост создан: id={scheduled_post.id}")
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Пост запланирован на {scheduled_dt.strftime('%d.%m.%Y %H:%M')}",
|
||
"post_id": scheduled_post.id
|
||
})
|
||
|
||
except ValueError as e:
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": f"Неверный формат времени: {e}"
|
||
}, status_code=400)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания поста: {e}")
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": str(e)
|
||
}, status_code=500)
|
||
|
||
|
||
@app.get("/api/scheduled_posts/list")
|
||
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
||
"""Список запланированных постов"""
|
||
from datetime import timedelta
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc())
|
||
result = await session.execute(stmt)
|
||
posts = list(result.scalars().all())
|
||
|
||
# Часовой пояс пользователя (UTC+4 для Ульяновска)
|
||
user_tz_offset = timedelta(hours=4) # Europe/Ulyanovsk
|
||
|
||
posts_data = []
|
||
for post in posts:
|
||
# Конвертируем UTC в локальное время
|
||
local_time = post.scheduled_time + user_tz_offset
|
||
|
||
posts_data.append({
|
||
"id": post.id,
|
||
"text": post.text,
|
||
"topic_name": post.topic_name,
|
||
"topic_emoji": post.get_topic_emoji(),
|
||
"recipients": post.recipients,
|
||
"scheduled_time": local_time.strftime('%d.%m.%Y %H:%M'), # Уже локальное!
|
||
"status": post.status,
|
||
"status_emoji": post.get_status_emoji(),
|
||
"created_at": fmt_local(post.created_at),
|
||
"has_photo": bool(post.photo_file_id)
|
||
})
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"posts": posts_data,
|
||
"timezone": "UTC+4 (Ульяновск/Самара)"
|
||
})
|
||
|
||
|
||
@app.post("/api/scheduled_posts/{post_id}/cancel")
|
||
async def api_cancel_scheduled_post(post_id: int, username: str = Depends(get_current_admin)):
|
||
"""Отменить запланированный пост"""
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(ScheduledPost).where(ScheduledPost.id == post_id)
|
||
result = await session.execute(stmt)
|
||
post = result.scalar_one_or_none()
|
||
|
||
if not post:
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": "Пост не найден"
|
||
}, status_code=404)
|
||
|
||
if post.status != 'pending':
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": f"Нельзя отменить пост со статусом {post.status}"
|
||
}, status_code=400)
|
||
|
||
post.status = 'cancelled'
|
||
await session.commit()
|
||
|
||
logger.info(f"🚫 Пост отменён: id={post_id}")
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": "Пост отменён"
|
||
})
|
||
|
||
|
||
# ============================================================================
|
||
# НАСТРОЙКИ (перенесено в /broadcast)
|
||
# ============================================================================
|
||
# Настройка лимита рассылки ИГ теперь в /broadcast
|
||
|
||
|
||
@app.post("/api/settings/ig_broadcast_limit")
|
||
async def api_set_ig_broadcast_limit(
|
||
request: Request,
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Установить лимит рассылки ИГ"""
|
||
from sqlalchemy import text
|
||
data = await request.json()
|
||
limit = data.get('limit', 0)
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(text('''
|
||
INSERT OR REPLACE INTO settings (key, value, description)
|
||
VALUES ('ig_broadcast_limit', :limit, 'Лимит рассылки ИГ в секундах')
|
||
'''), {"limit": str(limit)})
|
||
await session.commit()
|
||
|
||
return JSONResponse({"success": True, "message": f"Лимит установлен: {limit} сек"})
|
||
|
||
|
||
@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):
|
||
"""Запуск веб-сервера"""
|
||
import uvicorn
|
||
uvicorn.run(app, host=host, port=port)
|
||
|
||
|
||
|
||
@app.post("/api/broadcast/send_reminder/{broadcast_id}")
|
||
async def api_send_broadcast_reminder(broadcast_id: int, username: str = Depends(get_current_admin)):
|
||
"""Отправить напоминание непрочитавшим"""
|
||
from handlers.smart_broadcast import send_reminder_to_unread
|
||
from bot_instance import get_bot
|
||
|
||
bot = get_bot()
|
||
sent_count = await send_reminder_to_unread(bot, broadcast_id)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Напоминание отправлено {sent_count} пользователям"
|
||
})
|
||
|
||
|
||
@app.get("/api/broadcasts/list")
|
||
async def api_get_broadcasts_list(limit: int = 10, username: str = Depends(get_current_admin)):
|
||
"""Получить список последних рассылок"""
|
||
from database.models import Broadcast, BroadcastRead
|
||
from sqlalchemy import func, desc
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = select(Broadcast).order_by(Broadcast.created_at.desc()).limit(limit)
|
||
result = await session.execute(stmt)
|
||
broadcasts = list(result.scalars().all())
|
||
|
||
total = (await session.execute(select(func.count(Broadcast.id)))).scalar() or 0
|
||
|
||
broadcasts_data = []
|
||
for b in broadcasts:
|
||
read_count = (await session.execute(
|
||
select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id)
|
||
)).scalar() or 0
|
||
|
||
broadcasts_data.append({
|
||
'id': b.id,
|
||
'text': b.text,
|
||
'sent_at': fmt_local(b.sent_at) if b.sent_at else '-',
|
||
'total_sent': b.total_sent,
|
||
'read_count': read_count,
|
||
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
|
||
'broadcast_type': b.broadcast_type,
|
||
})
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"total": total,
|
||
"broadcasts": broadcasts_data
|
||
})
|
||
|
||
|
||
@app.post("/api/broadcast/create")
|
||
async def api_create_broadcast(
|
||
text: str = Form(...),
|
||
photo: UploadFile = File(None),
|
||
recipients: str = Form("all_and_chat"),
|
||
has_read_button: bool = Form(True),
|
||
dry_run: bool = Form(False), # Режим теста БЕЗ отправки
|
||
username: str = Depends(get_current_admin)
|
||
):
|
||
"""Создать и отправить рассылку из веб-панели"""
|
||
|
||
# dry_run = тестирование без реальной отправки
|
||
if dry_run:
|
||
from database.models import User
|
||
from services.initiative_group import InitiativeGroupService
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
if recipients == 'ig_only':
|
||
ig_service = InitiativeGroupService(session)
|
||
ig_user_ids = await ig_service.get_member_ids(active_only=True)
|
||
stmt = select(User).where(User.user_id.in_(ig_user_ids))
|
||
elif recipients == 'all_verified':
|
||
stmt = select(User).where(User.verified == True)
|
||
else:
|
||
stmt = select(User).where(User.verified == True)
|
||
|
||
result = await session.execute(stmt)
|
||
users = list(result.scalars().all())
|
||
|
||
logger.info(f"🧪 DRY RUN рассылки: получатели={recipients}, пользователей={len(users)}")
|
||
return JSONResponse({
|
||
"success": True,
|
||
"dry_run": True,
|
||
"message": f"Тестовый режим. Рассылка получила бы {len(users)} пользователей",
|
||
"recipients_count": len(users)
|
||
})
|
||
|
||
import aiohttp
|
||
from database.models import Broadcast
|
||
from datetime import datetime
|
||
|
||
photo_file_id = None
|
||
|
||
# Если есть фото - загружаем через Telegram Bot API
|
||
if photo and photo.filename:
|
||
try:
|
||
# Сохраняем временно
|
||
temp_path = Path("data") / f"broadcast_{photo.filename}"
|
||
content = await photo.read()
|
||
with open(temp_path, "wb") as f:
|
||
f.write(content)
|
||
|
||
# Загружаем фото через Telegram API для получения file_id
|
||
async with aiohttp.ClientSession() as session:
|
||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendPhoto"
|
||
|
||
# Если бот использует прокси - добавляем его в aiohttp
|
||
proxy = config.get_proxy_url()
|
||
|
||
data = aiohttp.FormData()
|
||
data.add_field('chat_id', config.ADMIN_USER_ID)
|
||
|
||
# Открываем файл и сразу передаём в FormData
|
||
with open(temp_path, 'rb') as photo_file:
|
||
data.add_field('photo', photo_file, filename=photo.filename)
|
||
data.add_field('caption', 'broadcast_test')
|
||
data.add_field('parse_mode', 'HTML')
|
||
|
||
async with session.post(url, data=data, proxy=proxy) as resp:
|
||
text = await resp.text()
|
||
|
||
# Проверяем что ответ успешный
|
||
if resp.status != 200:
|
||
logger.error(f"Ошибка загрузки фото: HTTP {resp.status}, ответ: {text}")
|
||
raise Exception(f"Telegram API вернул ошибку: HTTP {resp.status}")
|
||
|
||
# Пытаемся распарсить JSON
|
||
try:
|
||
result = json.loads(text)
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"Ошибка парсинга JSON от Telegram API: {e}, ответ: {text}")
|
||
raise Exception(f"Telegram API вернул не-JSON ответ: {text[:100]}")
|
||
|
||
if result.get('ok'):
|
||
photo_file_id = result['result']['photo'][-1]['file_id']
|
||
logger.info(f"✅ Фото загружено, file_id: {photo_file_id}")
|
||
else:
|
||
error_msg = result.get('description', 'Неизвестная ошибка')
|
||
logger.error(f"Ошибка загрузки фото: {error_msg}")
|
||
raise Exception(f"Ошибка Telegram API: {error_msg}")
|
||
|
||
# Удаляем временный файл
|
||
if temp_path.exists():
|
||
temp_path.unlink()
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Ошибка загрузки фото для рассылки: {e}")
|
||
# Не падаем, просто продолжаем без фото
|
||
photo_file_id = None
|
||
|
||
|
||
# Получаем бота из глобального экземпляра
|
||
from bot_instance import get_bot
|
||
bot = get_bot()
|
||
|
||
# Отправляем рассылку
|
||
try:
|
||
from handlers.smart_broadcast import send_smart_broadcast
|
||
success = await send_smart_broadcast(bot, text, recipients, photo_file_id)
|
||
|
||
if success:
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Рассылка отправлена получателям: {recipients}"
|
||
})
|
||
else:
|
||
return JSONResponse({
|
||
"error": "Ошибка при отправке рассылки. Проверьте логи бота."
|
||
}, status_code=500)
|
||
except Exception as e:
|
||
logger.error(f"❌ Критическая ошибка при отправке рассылки: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
return JSONResponse({
|
||
"error": f"Критическая ошибка: {str(e)}"
|
||
}, status_code=500)
|
||
|
||
|
||
@app.get("/api/broadcast/readers/{broadcast_id}")
|
||
async def api_get_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
|
||
"""Получить список прочитавших"""
|
||
from database.models import BroadcastRead, User
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
stmt = (
|
||
select(User, BroadcastRead.read_at)
|
||
.join(BroadcastRead, User.user_id == BroadcastRead.user_id)
|
||
.where(BroadcastRead.broadcast_id == broadcast_id)
|
||
.order_by(BroadcastRead.read_at.desc())
|
||
)
|
||
result = await session.execute(stmt)
|
||
rows = result.all()
|
||
|
||
readers = [
|
||
{
|
||
'user_id': user.user_id,
|
||
'name': user.full_name,
|
||
'apartment': user.apartment,
|
||
'read_at': fmt_local(read_at),
|
||
}
|
||
for user, read_at in rows
|
||
]
|
||
|
||
return JSONResponse({"success": True, "readers": readers})
|
||
|
||
|
||
@app.get("/api/broadcast/non_readers/{broadcast_id}")
|
||
async def api_get_broadcast_non_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
|
||
"""Получить список НЕ прочитавших"""
|
||
from database.models import BroadcastRead, User
|
||
from sqlalchemy import select as sql_select, not_
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
# Кто прочитал
|
||
read_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id)
|
||
read_result = await session.execute(read_stmt)
|
||
read_user_ids = set(row[0] for row in read_result.all())
|
||
|
||
# Все верифицированные
|
||
all_stmt = select(User).where(User.verified == True)
|
||
all_result = await session.execute(all_stmt)
|
||
all_users = list(all_result.scalars().all())
|
||
|
||
# Кто НЕ прочитал
|
||
non_readers = [
|
||
{
|
||
'user_id': u.user_id,
|
||
'name': u.full_name,
|
||
'apartment': u.apartment,
|
||
}
|
||
for u in all_users if u.user_id not in read_user_ids
|
||
]
|
||
|
||
return JSONResponse({"success": True, "non_readers": non_readers})
|
||
|
||
|
||
# ============================================================================
|
||
# V3.1 - ДАЙДЖЕСТЫ
|
||
# ============================================================================
|
||
|
||
@app.post("/api/digest/generate")
|
||
async def api_generate_digest(username: str = Depends(get_current_admin)):
|
||
"""Сгенерировать дайджест прямо сейчас (из веб-панели)"""
|
||
from services.digest_service import DigestService
|
||
from database.models import Digest
|
||
from datetime import date
|
||
|
||
try:
|
||
async with AsyncSessionLocal() as session:
|
||
service = DigestService(session)
|
||
|
||
# Генерируем
|
||
digest_data = await service.generate_weekly_digest()
|
||
|
||
if not digest_data:
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": "Не удалось сгенерировать дайджест. Проверьте логи."
|
||
}, status_code=500)
|
||
|
||
# Проверяем нет ли уже за эту неделю
|
||
check_stmt = select(Digest).where(
|
||
Digest.week_number == digest_data['week_number'],
|
||
Digest.year == digest_data['year']
|
||
)
|
||
result = await session.execute(check_stmt)
|
||
existing = result.scalar_one_or_none()
|
||
|
||
if existing:
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Дайджест за неделю #{digest_data['week_number']}/{digest_data['year']} уже существует (ID: #{existing.id}). Вы можете отправить его в разделе 'Архив'.",
|
||
"digest_id": existing.id,
|
||
"already_exists": True
|
||
})
|
||
|
||
# Сохраняем
|
||
digest_id = await service.save_digest(digest_data, status='pending_approval')
|
||
|
||
# Отправляем превью админу в личку через HTTP
|
||
from config import get_proxy_url
|
||
|
||
async with aiohttp.ClientSession() as http_session:
|
||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||
keyboard = {
|
||
"inline_keyboard": [
|
||
[{"text": "🌐 Утвердить в веб-панели", "url": "http://localhost:8000/digests"}]
|
||
]
|
||
}
|
||
payload = {
|
||
'chat_id': config.ADMIN_USER_ID,
|
||
'text': f"📰 <b>Дайджест за неделю готов!</b>\n\n"
|
||
f"Неделя #{digest_data['week_number']}/{digest_data['year']}\n"
|
||
f"Период: {digest_data['period_start'].strftime('%d.%m')} - {digest_data['period_end'].strftime('%d.%m.%Y')}\n\n"
|
||
f"Зайдите в веб-панель для утверждения:\n"
|
||
f"http://localhost:8000/digests",
|
||
'parse_mode': 'HTML',
|
||
'reply_markup': json.dumps(keyboard)
|
||
}
|
||
async with http_session.post(url, json=payload, proxy=get_proxy_url()) as resp:
|
||
await resp.json()
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"✅ Дайджест #{digest_id} сгенерирован! Превью отправлено вам в Telegram.",
|
||
"digest_id": digest_id
|
||
})
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
logger.error(f"Ошибка генерации дайджеста: {e}")
|
||
traceback.print_exc()
|
||
return JSONResponse({
|
||
"success": False,
|
||
"error": f"Критическая ошибка: {str(e)}"
|
||
}, status_code=500)
|
||
|
||
|
||
@app.get("/digests", response_class=HTMLResponse)
|
||
async def digests_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница дайджестов"""
|
||
from services.digest_service import DigestService
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
service = DigestService(session)
|
||
pending = await service.get_pending_digests()
|
||
archive = await service.get_digest_archive()
|
||
|
||
return templates.TemplateResponse("digests.html", {
|
||
"request": request,
|
||
"username": username,
|
||
"pending_digests": pending,
|
||
"archive_digests": archive,
|
||
})
|
||
|
||
|
||
@app.post("/api/digest/{digest_id}/approve")
|
||
async def api_approve_digest(digest_id: int, username: str = Depends(get_current_admin)):
|
||
"""Утвердить дайджест"""
|
||
from services.digest_service import DigestService
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
service = DigestService(session)
|
||
success = await service.update_digest_status(
|
||
digest_id,
|
||
'approved',
|
||
approved_by=config.ADMIN_USER_ID
|
||
)
|
||
|
||
if success:
|
||
return JSONResponse({"success": True, "message": "Дайджест утверждён"})
|
||
else:
|
||
return JSONResponse({"error": "Ошибка утверждения"}, status_code=500)
|
||
|
||
|
||
@app.post("/api/digest/{digest_id}/reject")
|
||
async def api_reject_digest(digest_id: int, request: Request, username: str = Depends(get_current_admin)):
|
||
"""Отклонить дайджест"""
|
||
from services.digest_service import DigestService
|
||
|
||
data = await request.json()
|
||
reason = data.get('reason', 'Не указана')
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
service = DigestService(session)
|
||
success = await service.update_digest_status(
|
||
digest_id,
|
||
'rejected',
|
||
rejected_by=config.ADMIN_USER_ID,
|
||
rejection_reason=reason
|
||
)
|
||
|
||
if success:
|
||
return JSONResponse({"success": True, "message": "Дайджест отклонён"})
|
||
else:
|
||
return JSONResponse({"error": "Ошибка отклонения"}, status_code=500)
|
||
|
||
|
||
@app.post("/api/digest/{digest_id}/send")
|
||
async def api_send_digest(digest_id: int, username: str = Depends(get_current_admin)):
|
||
"""Отправить утверждённый дайджест"""
|
||
from services.digest_service import DigestService
|
||
from database.models import User, Digest
|
||
import aiohttp
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
# Получаем дайджест
|
||
digest = await session.get(Digest, digest_id)
|
||
if not digest or digest.status != 'approved':
|
||
return JSONResponse({"error": "Дайджест не утверждён"}, status_code=400)
|
||
|
||
# Получаем всех верифицированных
|
||
stmt = select(User).where(User.verified == True)
|
||
result = await session.execute(stmt)
|
||
users = list(result.scalars().all())
|
||
|
||
text = digest.digest_text
|
||
sent_count = 0
|
||
error_count = 0
|
||
message_id = None
|
||
|
||
# Сначала в чат
|
||
try:
|
||
async with aiohttp.ClientSession() as http_session:
|
||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||
params = {
|
||
'chat_id': config.ADMIN_CHAT_ID,
|
||
'text': f"📰 Еженедельный дайджест\n\n{text}",
|
||
'parse_mode': 'HTML'
|
||
}
|
||
async with http_session.post(url, json=params, timeout=30) as resp:
|
||
if resp.status == 200:
|
||
resp_data = await resp.json()
|
||
message_id = resp_data['result']['message_id']
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки в чат: {e}")
|
||
|
||
# Потом пользователям
|
||
async with aiohttp.ClientSession() as http_session:
|
||
for user in users:
|
||
try:
|
||
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/sendMessage"
|
||
params = {
|
||
'chat_id': user.user_id,
|
||
'text': f"📰 Еженедельный дайджест\n\n{text}",
|
||
'parse_mode': 'HTML'
|
||
}
|
||
async with http_session.post(url, json=params, timeout=10) as resp:
|
||
if resp.status == 200:
|
||
sent_count += 1
|
||
except Exception as e:
|
||
logger.error(f"Ошибка отправки пользователю {user.user_id}: {e}")
|
||
error_count += 1
|
||
|
||
# Обновляем статус
|
||
service = DigestService(session)
|
||
await service.update_digest_status(digest_id, 'sent', message_id=message_id)
|
||
|
||
return JSONResponse({
|
||
"success": True,
|
||
"message": f"Дайджест отправлен {sent_count} пользователям, ошибок: {error_count}"
|
||
})
|
||
|
||
|
||
@app.get("/api/digest/{digest_id}")
|
||
async def api_get_digest(digest_id: int, username: str = Depends(get_current_admin)):
|
||
"""Получить данные дайджеста"""
|
||
from database.models import Digest
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
digest = await session.get(Digest, digest_id)
|
||
|
||
if not digest:
|
||
return JSONResponse({"error": "Дайджест не найден"}, status_code=404)
|
||
|
||
return JSONResponse({
|
||
"digest": {
|
||
"id": digest.id,
|
||
"week": digest.week_number,
|
||
"year": digest.year,
|
||
"period_label": digest.period_label,
|
||
"status": digest.status,
|
||
"text": digest.digest_text or '',
|
||
"created_at": fmt_local(digest.created_at),
|
||
}
|
||
})
|
||
|
||
|
||
@app.post("/api/digest/{digest_id}/update_text")
|
||
async def api_update_digest_text(digest_id: int, request: Request, username: str = Depends(get_current_admin)):
|
||
"""Обновить текст дайджеста"""
|
||
from services.digest_service import DigestService
|
||
|
||
data = await request.json()
|
||
new_text = data.get('text', '')
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
service = DigestService(session)
|
||
success = await service.update_digest_text(digest_id, new_text)
|
||
|
||
if success:
|
||
return JSONResponse({"success": True, "message": "Текст обновлён"})
|
||
else:
|
||
return JSONResponse({"error": "Ошибка обновления текста"}, status_code=500)
|
||
|
||
|
||
@app.get("/digest_settings", response_class=HTMLResponse)
|
||
async def digest_settings_page(request: Request, username: str = Depends(get_current_admin)):
|
||
"""Страница настроек дайджеста"""
|
||
return templates.TemplateResponse("digest_settings.html", {
|
||
"request": request,
|
||
"username": username,
|
||
})
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_web_server()
|