""" 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 import secrets import config 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': b.sent_at.strftime('%d.%m.%Y %H:%M') 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'), # Локальное время! "scheduled_time_utc": post.scheduled_time.strftime('%d.%m.%Y %H:%M'), # UTC для отладки "status": post.status, "status_emoji": post.get_status_emoji(), "created_at": post.created_at.strftime('%d.%m.%Y %H:%M'), "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': b.sent_at.strftime('%d.%m.%Y %H:%M') 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': read_at.strftime('%d.%m.%Y %H:%M'), } 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.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": digest.created_at.strftime('%d.%m.%Y %H:%M'), } }) @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()