domovoy_bot/web/app.py

481 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Web Admin Interface v4.5 REBORN - MEGA ADMIN PANEL
Полноценное управление ВСЕМ функционалом бота:
- Dashboard, Users, Verification, Phones, Ads, Polls, Events, Schedules
- Smart Broadcasts (с отслеживанием прочтения)
- Scheduled Posts (с фото/документами)
- Weekly Digests (v3.1)
- Chat Exporter (JSON + Gitea Push)
- Email Auditor (аудит переписки)
"""
import logging
import json
import aiohttp
import re
import os
import sys
import secrets
import subprocess
from pathlib import Path
from datetime import datetime, timedelta, timezone
# ГАРАНТИРУЕМ, что корень проекта в пути поиска модулей
BASE_DIR = Path(__file__).parent.parent
if str(BASE_DIR) not in sys.path:
sys.path.insert(0, str(BASE_DIR))
from fastapi import FastAPI, Request, Depends, HTTPException, status, Form, File, UploadFile, Query
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update, delete, desc, text
import config
from database.db import AsyncSessionLocal
from database.models import (
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event, ScheduledPost,
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
ProfileHistory, Achievement, Thank, SpyLog
)
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
ULY_TZ = timezone(timedelta(hours=4))
def to_local(dt) -> datetime:
if dt is None: return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt.replace(' ', 'T'))
except: return None
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(ULY_TZ)
def fmt_local(dt, fmt: str = '%d.%m.%Y %H:%M') -> str:
local_dt = to_local(dt)
return local_dt.strftime(fmt) if local_dt else '-'
logger = logging.getLogger(__name__)
logger.info(f"!!! [STARTUP] Unified Web App loaded. PID: {os.getpid()}")
app = FastAPI(title="LKM37 MEGA Admin Panel", version="4.5.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
templates = Jinja2Templates(directory=str(BASE_DIR / "web" / "templates"))
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "web" / "static")), name="static")
app.mount("/static/service_images", StaticFiles(directory=str(BASE_DIR / "data" / "service_images")), name="service_images")
app.mount("/static/email_archive", StaticFiles(directory=str(BASE_DIR / "services" / "email_auditor" / "exported_emails")), name="email_archive")
security = HTTPBasic()
def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
correct_username = secrets.compare_digest(credentials.username, config.WEB_ADMIN_LOGIN)
correct_password = secrets.compare_digest(credentials.password, config.WEB_ADMIN_PASSWORD)
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect login or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
# ============================================================================
# ГЛАВНАЯ (DASHBOARD)
# ============================================================================
@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(DBMessage.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()
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':
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_user_ids = [row[0] for row in (await session.execute(ig_stmt)).all()]
stmt = stmt.where(User.user_id.in_(ig_user_ids))
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())
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_user_ids = set(row[0] for row in (await session.execute(ig_stmt)).all())
pending_stmt = select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc())
pending_requests = (await session.execute(pending_stmt)).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()
active_unverified = (await session.execute(select(func.count(User.user_id)).where(User.verified == False, User.message_count >= 50))).scalar()
return templates.TemplateResponse("users.html", {
"request": request, "username": username, "users": users, "search": search, "current_filter": filter,
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "active_unverified": active_unverified, "ig": len(ig_user_ids)},
"ig_user_ids": ig_user_ids, "pending_count": len(list(pending_requests))
})
@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": "Not found"}, 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}/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 '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.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})
@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})
# ============================================================================
# ВЕРИФИКАЦИЯ
# ============================================================================
@app.get("/verification", response_class=HTMLResponse)
async def verification_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
pending = (await session.execute(select(VerificationRequest).where(VerificationRequest.status == 'pending').order_by(VerificationRequest.created_at.desc()))).scalars().all()
unverified = (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 = (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.get("/ads", response_class=HTMLResponse)
async def ads_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
ads = (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.get("/polls", response_class=HTMLResponse)
async def polls_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
polls = (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.get("/events", response_class=HTMLResponse)
async def events_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
events = (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.get("/schedules", response_class=HTMLResponse)
async def schedules_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
schedules = (await session.execute(select(Schedule).order_by(Schedule.start_time.desc()))).scalars().all()
return templates.TemplateResponse("schedules.html", {"request": request, "username": username, "schedules": schedules})
# ============================================================================
# РАССЫЛКИ (SMART BROADCAST)
# ============================================================================
@app.get("/broadcast", response_class=HTMLResponse)
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
broadcasts = (await session.execute(select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50))).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,
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
'broadcast_type': b.broadcast_type or 'regular',
})
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data})
# ============================================================================
# ЗАПЛАНИРОВАННЫЕ ПОСТЫ
# ============================================================================
@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.get("/api/scheduled_posts/list")
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
posts = (await session.execute(select(ScheduledPost).order_by(ScheduledPost.scheduled_time.desc()))).scalars().all()
posts_data = []
for post in posts:
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": fmt_local(post.scheduled_time), "status": post.status,
"status_emoji": post.get_status_emoji(), "has_photo": bool(post.photo_file_id)
})
return JSONResponse({"success": True, "posts": posts_data})
# ============================================================================
# ДАЙДЖЕСТЫ (V3.1)
# ============================================================================
@app.get("/digests", response_class=HTMLResponse)
async def digests_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
# Получаем список дайджестов из БД
stmt_pending = select(Digest).where(Digest.status.in_(['draft', 'pending_approval'])).order_by(Digest.created_at.desc())
pending = (await session.execute(stmt_pending)).scalars().all()
stmt_archive = select(Digest).where(Digest.status.in_(['approved', 'sent', 'rejected'])).order_by(Digest.created_at.desc()).limit(20)
archive = (await session.execute(stmt_archive)).scalars().all()
return templates.TemplateResponse("digests.html", {
"request": request,
"username": username,
"pending_digests": pending,
"archive_digests": archive
})
@app.post("/api/digest/generate")
async def api_generate_digest(username: str = Depends(get_current_admin)):
from services.digest_service import DigestService
try:
stats = await DigestService.collect_weekly_stats()
digest_text = DigestService.format_digest_text(stats)
now = datetime.now()
week_num = now.isocalendar()[1]
async with AsyncSessionLocal() as session:
new_digest = Digest(
year=now.year,
week_number=week_num,
period_start=now - timedelta(days=7),
period_end=now,
digest_text=digest_text,
status='draft'
)
session.add(new_digest)
await session.commit()
return JSONResponse({"success": True, "message": f"Дайджест за {week_num} неделю успешно сгенерирован (черновик)"})
except Exception as e:
logger.error(f"Error generating digest: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/digest/{digest_id}")
async def api_get_digest(digest_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
digest = await session.get(Digest, digest_id)
if not digest: return JSONResponse({"error": "Not found"}, status_code=404)
return JSONResponse({
"digest": {
"id": digest.id,
"week": digest.week_number,
"year": digest.year,
"text": digest.digest_text,
"status": digest.status
}
})
# ============================================================================
# ФАЙЛЫ (FILES)
# ============================================================================
@app.get("/files", response_class=HTMLResponse)
async def files_page(request: Request, username: str = Depends(get_current_admin)):
return templates.TemplateResponse("files.html", {"request": request, "username": username})
@app.get("/api/files/list")
async def api_list_files(username: str = Depends(get_current_admin)):
files_data = []
# 1. Экспорты
export_path = BASE_DIR / "exports"
if export_path.exists():
for f in export_path.glob("*.json"):
files_data.append({
"filename": f.name,
"file_type": "EXPORT",
"size": f"{round(f.stat().st_size / 1024, 1)} KB",
"path": f"/exports/{f.name}"
})
# 2. Логи
log_path = BASE_DIR / "logs"
if log_path.exists():
for f in log_path.glob("*.log"):
files_data.append({
"filename": f.name,
"file_type": "LOG",
"size": f"{round(f.stat().st_size / 1024, 1)} KB",
"path": f"/api/files/download/logs/{f.name}"
})
return JSONResponse({"success": True, "files": files_data})
@app.get("/api/files/download/logs/{filename}")
async def download_log(filename: str, username: str = Depends(get_current_admin)):
filepath = BASE_DIR / "logs" / filename
if not filepath.exists(): raise HTTPException(status_code=404, detail="File not found")
return FileResponse(filepath, filename=filename)
# ============================================================================
# ЭКСПОРТ (JSON + GITEA)
# ============================================================================
@app.get("/export", response_class=HTMLResponse)
async def export_page(request: Request, username: str = Depends(get_current_admin)):
from services.chat_exporter import ChatExporter
async with AsyncSessionLocal() as session:
exporter = ChatExporter(session)
stats = await exporter.get_stats()
return templates.TemplateResponse("export.html", {"request": request, "username": username, "export_stats": stats, "fmt_local": fmt_local})
@app.get("/api/export/json")
async def api_export_json(username: str = Depends(get_current_admin)):
from services.chat_exporter import ChatExporter
try:
async with AsyncSessionLocal() as session:
exporter = ChatExporter(session)
result = await exporter.run_full_export()
if not result.get('success'): return JSONResponse(status_code=500, content=result)
return JSONResponse(result)
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/exports/{filename}")
async def download_export(filename: str, username: str = Depends(get_current_admin)):
filepath = BASE_DIR / "exports" / filename
if not filepath.exists(): raise HTTPException(status_code=404, detail="File not found")
return FileResponse(filepath, filename=filename, media_type='application/json')
# ============================================================================
# ПОЧТА (EMAIL AUDITOR)
# ============================================================================
@app.get("/emails", response_class=HTMLResponse)
async def emails_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
audit_data = (await session.execute(select(EmailAudit).order_by(EmailAudit.last_email_date.desc()))).scalars().all()
last_log = (await session.execute(select(EmailExportLog).order_by(EmailExportLog.start_time.desc()).limit(1))).scalar_one_or_none()
latest_emails = []
all_folder = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "all"
if all_folder.exists():
files = sorted(all_folder.glob("*.pdf"), key=lambda x: x.stat().st_mtime, reverse=True)[:10]
for f in files:
match = re.match(r'(\d{4}-\d{2}-\d{2})_([^_]+)_(.*)', f.stem)
if match: latest_emails.append({"date": match.group(1), "domain": match.group(2), "subject": match.group(3)})
master_pdf_path = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "TOTAL_ARCHIVE_2025-2026.pdf"
last_update = datetime.fromtimestamp(master_pdf_path.stat().st_mtime, tz=timezone.utc) if master_pdf_path.exists() else None
return templates.TemplateResponse("emails.html", {
"request": request, "username": username, "audit_data": audit_data, "last_log": last_log,
"latest_emails": latest_emails, "has_master_pdf": master_pdf_path.exists(), "last_update": last_update, "fmt_local": fmt_local
})
@app.post("/api/emails/run")
async def api_run_email_audit(username: str = Depends(get_current_admin)):
script_path = BASE_DIR / "services" / "email_auditor" / "export_emails_v2.py"
venv_python = BASE_DIR / "venv" / "bin" / "python3"
if not script_path.exists(): return JSONResponse(status_code=404, content={"message": "Script not found"})
try:
env = os.environ.copy()
env["PYTHONPATH"] = str(BASE_DIR)
process = subprocess.Popen([str(venv_python), str(script_path)], cwd=str(BASE_DIR / "services" / "email_auditor"), env=env, start_new_session=True)
return JSONResponse(content={"message": f"Audit started (PID: {process.pid})"})
except Exception as e:
return JSONResponse(status_code=500, content={"message": str(e)})
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)