v4.8: Восстановление Artifact Manager, поддержка фото+док в рассылках и постах, исправление API

This commit is contained in:
Admin 2026-04-18 23:34:14 +04:00
parent 05e5659204
commit aadfc041b4
6 changed files with 431 additions and 162 deletions

View file

@ -0,0 +1,26 @@
import asyncio
from sqlalchemy.ext.asyncio import create_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from database.models import Base
import config
import os
DATABASE_URL = f"sqlite+aiosqlite:///{os.path.abspath(config.DATABASE_PATH)}"
async def migrate():
print(f"🚀 Запуск миграции для Artifacts... База: {DATABASE_URL}")
engine = create_engine(DATABASE_URL, echo=True)
async with engine.begin() as conn:
# Это создаст только те таблицы, которых нет
await conn.run_sync(Base.metadata.create_all)
print("✅ Таблица artifacts создана (если её не было).")
if __name__ == "__main__":
from database.db import engine
async def run():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("✅ Миграция Artifacts завершена.")
asyncio.run(run())

View file

@ -792,3 +792,17 @@ class EmailExportLog(Base):
status = Column(String(50)) # 'success', 'error', 'running' status = Column(String(50)) # 'success', 'error', 'running'
new_emails_count = Column(Integer, default=0) new_emails_count = Column(Integer, default=0)
report_path = Column(String(255), nullable=True) report_path = Column(String(255), nullable=True)
class Artifact(Base):
"""Медиа-артефакты для повторного использования в рассылках"""
__tablename__ = 'artifacts'
id = Column(Integer, primary_key=True, autoincrement=True)
filename = Column(String(255), nullable=False)
file_type = Column(String(50), nullable=False) # 'photo', 'document'
file_id = Column(String(255), nullable=False, unique=True)
created_at = Column(DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<Artifact {self.filename} ({self.file_type})>"

View file

@ -1,12 +1,13 @@
""" """
Web Admin Interface v4.5 REBORN - MEGA ADMIN PANEL Web Admin Interface v4.8 REBORN - MEGA ADMIN PANEL
Полноценное управление ВСЕМ функционалом бота: Полноценное управление ВСЕМ функционалом бота:
- Dashboard, Users, Verification, Phones, Ads, Polls, Events, Schedules - Dashboard, Users, Verification, Phones, Ads, Polls, Events, Schedules
- Smart Broadcasts (с отслеживанием прочтения) - Smart Broadcasts (фото + док через Artifacts)
- Scheduled Posts (с фото/документами) - Scheduled Posts (фото + док через Artifacts)
- Weekly Digests (v3.1) - Weekly Digests (v3.1)
- Chat Exporter (JSON + Gitea Push) - Chat Exporter (JSON + Gitea Push)
- Email Auditor (аудит переписки) - Email Auditor (аудит переписки)
- Artifact Manager (хранилище медиа ID)
""" """
import logging import logging
import json import json
@ -16,6 +17,7 @@ import os
import sys import sys
import secrets import secrets
import subprocess import subprocess
import uuid
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -39,7 +41,7 @@ from database.models import (
User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule, User, Message as DBMessage, Poll, Ad, PaymentReminder, Schedule,
VerificationRequest, Service, Announcement, Event, ScheduledPost, VerificationRequest, Service, Announcement, Event, ScheduledPost,
Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog, Broadcast, BroadcastRead, Digest, InitiativeGroup, EmailAudit, EmailExportLog,
ProfileHistory, Achievement, Thank, SpyLog ProfileHistory, Achievement, Thank, SpyLog, Artifact
) )
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) ===== # ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
@ -61,7 +63,7 @@ def fmt_local(dt, fmt: str = '%d.%m.%Y %H:%M') -> str:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f"!!! [STARTUP] Unified Web App loaded. PID: {os.getpid()}") logger.info(f"!!! [STARTUP] Unified Web App loaded. PID: {os.getpid()}")
app = FastAPI(title="LKM37 MEGA Admin Panel", version="4.5.0") app = FastAPI(title="LKM37 MEGA Admin Panel", version="4.8.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -89,6 +91,30 @@ def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
) )
return credentials.username return credentials.username
# Вспомогательная функция для загрузки в TG
async def upload_to_tg(content, filename, content_type):
file_type = "document"
if content_type.startswith("image/"): file_type = "photo"
proxy_url = f"http://{config.PROXY_HOST}:{config.PROXY_PORT}" if config.USE_PROXY else None
async with aiohttp.ClientSession() as session:
data = aiohttp.FormData()
data.add_field(file_type, content, filename=filename, content_type=content_type)
data.add_field('chat_id', str(config.ADMIN_USER_ID))
data.add_field('caption', f"📤 Загрузка через веб-панель: {filename}")
method = "sendPhoto" if file_type == "photo" else "sendDocument"
url = f"https://api.telegram.org/bot{config.BOT_TOKEN}/{method}"
async with session.post(url, data=data, proxy=proxy_url) as resp:
tg_res = await resp.json()
if not tg_res.get("ok"):
raise Exception(f"TG Error: {tg_res.get('description')}")
msg = tg_res["result"]
file_id = msg["photo"][-1]["file_id"] if file_type == "photo" else msg["document"]["file_id"]
return file_id, file_type
# ============================================================================ # ============================================================================
# ГЛАВНАЯ (DASHBOARD) # ГЛАВНАЯ (DASHBOARD)
# ============================================================================ # ============================================================================
@ -144,26 +170,15 @@ async def users_page(request: Request, search: str = "", filter: str = "", usern
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True) 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()) 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() 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() 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", { return templates.TemplateResponse("users.html", {
"request": request, "username": username, "users": users, "search": search, "current_filter": filter, "request": request, "username": username, "users": users, "search": search, "current_filter": filter,
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "active_unverified": active_unverified, "ig": len(ig_user_ids)}, "stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
"ig_user_ids": ig_user_ids, "pending_count": len(list(pending_requests)) "ig_user_ids": ig_user_ids
}) })
@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") @app.post("/api/user/{user_id}/update")
async def api_update_user(user_id: int, request: Request, username: str = Depends(get_current_admin)): async def api_update_user(user_id: int, request: Request, username: str = Depends(get_current_admin)):
data = await request.json() data = await request.json()
@ -174,26 +189,11 @@ async def api_update_user(user_id: int, request: Request, username: str = Depend
if 'verified' in data: if 'verified' in data:
update_data['verified'] = bool(data['verified']) update_data['verified'] = bool(data['verified'])
if data['verified']: update_data['verification_date'] = datetime.utcnow() if data['verified']: update_data['verification_date'] = datetime.utcnow()
if 'rating' in data: update_data['rating'] = int(data['rating'])
if update_data: if update_data:
await session.execute(update(User).where(User.user_id == user_id).values(**update_data)) await session.execute(update(User).where(User.user_id == user_id).values(**update_data))
await session.commit() await session.commit()
return JSONResponse({"success": True}) 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})
# ============================================================================ # ============================================================================
# ВЕРИФИКАЦИЯ # ВЕРИФИКАЦИЯ
# ============================================================================ # ============================================================================
@ -268,6 +268,8 @@ async def schedules_page(request: Request, username: str = Depends(get_current_a
async def broadcast_page(request: Request, username: str = Depends(get_current_admin)): async def broadcast_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
broadcasts = (await session.execute(select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50))).scalars().all() broadcasts = (await session.execute(select(Broadcast).order_by(Broadcast.created_at.desc()).limit(50))).scalars().all()
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
broadcasts_data = [] broadcasts_data = []
for b in broadcasts: for b in broadcasts:
read_count = (await session.execute(select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id))).scalar() or 0 read_count = (await session.execute(select(func.count(BroadcastRead.id)).where(BroadcastRead.broadcast_id == b.id))).scalar() or 0
@ -277,8 +279,70 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
'total_sent': b.total_sent, 'read_count': read_count, '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, 'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
'broadcast_type': b.broadcast_type or 'regular', 'broadcast_type': b.broadcast_type or 'regular',
'is_reminder_sent': b.is_reminder_sent
}) })
return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data}) return templates.TemplateResponse("broadcast.html", {"request": request, "username": username, "broadcasts": broadcasts_data, "artifacts": artifacts})
@app.post("/api/broadcast/create")
async def api_create_broadcast(
text: str = Form(...),
photo: UploadFile = File(None),
artifact_photo_id: str = Form(None),
artifact_document_id: str = Form(None),
recipients: str = Form("all_and_chat"),
staircase: str = Form("all"),
has_read_button: bool = Form(True),
track_reads: bool = Form(True),
username: str = Depends(get_current_admin)
):
try:
from database.models import User, Broadcast
photo_file_id = artifact_photo_id
document_file_id = artifact_document_id
# Если загружена новая картинка
if photo and photo.filename and not photo_file_id:
content = await photo.read()
photo_file_id, _ = await upload_to_tg(content, photo.filename, photo.content_type)
async with AsyncSessionLocal() as session:
stmt = select(User).where(User.verified == True, User.is_banned == False)
if staircase != 'all': pass
if recipients == 'ig_only':
ig_stmt = select(InitiativeGroup.user_id).where(InitiativeGroup.is_active == True)
ig_ids = [r[0] for row in (await session.execute(ig_stmt)).all()]
stmt = stmt.where(User.user_id.in_(ig_ids))
users = (await session.execute(stmt)).scalars().all()
new_broadcast = Broadcast(
text=text, photo_file_id=photo_file_id,
sent_at=datetime.utcnow(), total_sent=len(users),
broadcast_type='smart' if track_reads else 'regular'
)
session.add(new_broadcast)
await session.commit()
return JSONResponse({"success": True, "message": f"Рассылка создана. Получателей: {len(users)}", "broadcast_id": new_broadcast.id})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/broadcast/readers/{broadcast_id}")
async def api_broadcast_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
stmt = select(User.first_name, User.last_name, User.apartment, BroadcastRead.read_at).join(BroadcastRead).where(BroadcastRead.broadcast_id == broadcast_id)
results = (await session.execute(stmt)).all()
readers = [{"name": f"{r[0]} {r[1] or ''}", "apartment": r[2], "read_at": fmt_local(r[3])} for r in results]
return JSONResponse({"readers": readers})
@app.get("/api/broadcast/non_readers/{broadcast_id}")
async def api_broadcast_non_readers(broadcast_id: int, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session:
read_ids_stmt = select(BroadcastRead.user_id).where(BroadcastRead.broadcast_id == broadcast_id)
read_ids = [r[0] for r in (await session.execute(read_ids_stmt)).all()]
stmt = select(User.first_name, User.last_name, User.apartment).where(User.verified == True, User.user_id.not_in(read_ids))
results = (await session.execute(stmt)).all()
non_readers = [{"name": f"{r[0]} {r[1] or ''}", "apartment": r[2]} for r in results]
return JSONResponse({"non_readers": non_readers})
# ============================================================================ # ============================================================================
# ЗАПЛАНИРОВАННЫЕ ПОСТЫ # ЗАПЛАНИРОВАННЫЕ ПОСТЫ
@ -286,7 +350,9 @@ async def broadcast_page(request: Request, username: str = Depends(get_current_a
@app.get("/scheduled_posts", response_class=HTMLResponse) @app.get("/scheduled_posts", response_class=HTMLResponse)
async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)): async def scheduled_posts_page(request: Request, username: str = Depends(get_current_admin)):
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username}) async with AsyncSessionLocal() as session:
artifacts = (await session.execute(select(Artifact).order_by(Artifact.filename))).scalars().all()
return templates.TemplateResponse("scheduled_posts.html", {"request": request, "username": username, "artifacts": artifacts})
@app.get("/api/scheduled_posts/list") @app.get("/api/scheduled_posts/list")
async def api_list_scheduled_posts(username: str = Depends(get_current_admin)): async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
@ -298,10 +364,53 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
"id": post.id, "text": post.text, "topic_name": post.topic_name, "id": post.id, "text": post.text, "topic_name": post.topic_name,
"topic_emoji": post.get_topic_emoji(), "recipients": post.recipients, "topic_emoji": post.get_topic_emoji(), "recipients": post.recipients,
"scheduled_time": fmt_local(post.scheduled_time), "status": post.status, "scheduled_time": fmt_local(post.scheduled_time), "status": post.status,
"status_emoji": post.get_status_emoji(), "has_photo": bool(post.photo_file_id) "status_emoji": post.get_status_emoji(), "has_photo": bool(post.photo_file_id or post.document_file_id)
}) })
return JSONResponse({"success": True, "posts": posts_data}) return JSONResponse({"success": True, "posts": posts_data})
@app.post("/api/scheduled_posts/create")
async def api_create_scheduled_post(
text: str = Form(...),
photo: UploadFile = File(None),
document: UploadFile = File(None),
artifact_photo_id: str = Form(None),
artifact_document_id: str = Form(None),
topic_name: str = Form("general"),
recipients: str = Form("chat_only"),
scheduled_time: str = Form(...),
username: str = Depends(get_current_admin)
):
try:
photo_file_id = artifact_photo_id
document_file_id = artifact_document_id
doc_name = None
if photo and photo.filename and not photo_file_id:
content = await photo.read()
photo_file_id, _ = await upload_to_tg(content, photo.filename, photo.content_type)
if document and document.filename and not document_file_id:
content = await document.read()
document_file_id, _ = await upload_to_tg(content, document.filename, document.content_type)
doc_name = document.filename
# Парсим время (оно приходит в локальном времени)
dt_local = datetime.fromisoformat(scheduled_time)
dt_utc = dt_local - timedelta(hours=4) # Ульяновск -> UTC
async with AsyncSessionLocal() as session:
new_post = ScheduledPost(
text=text, photo_file_id=photo_file_id,
document_file_id=document_file_id, document_name=doc_name,
topic_name=topic_name, recipients=recipients,
scheduled_time=dt_utc, status='pending'
)
session.add(new_post)
await session.commit()
return JSONResponse({"success": True, "message": f"Пост запланирован на {fmt_local(dt_utc)}"})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
# ============================================================================ # ============================================================================
# ДАЙДЖЕСТЫ (V3.1) # ДАЙДЖЕСТЫ (V3.1)
# ============================================================================ # ============================================================================
@ -309,19 +418,11 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
@app.get("/digests", response_class=HTMLResponse) @app.get("/digests", response_class=HTMLResponse)
async def digests_page(request: Request, username: str = Depends(get_current_admin)): async def digests_page(request: Request, username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# Получаем список дайджестов из БД
stmt_pending = select(Digest).where(Digest.status.in_(['draft', 'pending_approval'])).order_by(Digest.created_at.desc()) 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() 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) 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() archive = (await session.execute(stmt_archive)).scalars().all()
return templates.TemplateResponse("digests.html", {"request": request, "username": username, "pending_digests": pending, "archive_digests": archive})
return templates.TemplateResponse("digests.html", {
"request": request,
"username": username,
"pending_digests": pending,
"archive_digests": archive
})
@app.post("/api/digest/generate") @app.post("/api/digest/generate")
async def api_generate_digest(username: str = Depends(get_current_admin)): async def api_generate_digest(username: str = Depends(get_current_admin)):
@ -329,44 +430,18 @@ async def api_generate_digest(username: str = Depends(get_current_admin)):
try: try:
stats = await DigestService.collect_weekly_stats() stats = await DigestService.collect_weekly_stats()
digest_text = DigestService.format_digest_text(stats) digest_text = DigestService.format_digest_text(stats)
now = datetime.now() now = datetime.now()
week_num = now.isocalendar()[1] week_num = now.isocalendar()[1]
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
new_digest = Digest( 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')
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) session.add(new_digest)
await session.commit() await session.commit()
return JSONResponse({"success": True, "message": f"Дайджест за {week_num} неделю успешно сгенерирован (черновик)"}) return JSONResponse({"success": True, "message": f"Дайджест за {week_num} неделю успешно сгенерирован (черновик)"})
except Exception as e: 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)}) 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) # ФАЙЛЫ / АРТЕФАКТЫ (Artifact Manager)
# ============================================================================ # ============================================================================
@app.get("/files", response_class=HTMLResponse) @app.get("/files", response_class=HTMLResponse)
@ -375,32 +450,34 @@ async def files_page(request: Request, username: str = Depends(get_current_admin
@app.get("/api/files/list") @app.get("/api/files/list")
async def api_list_files(username: str = Depends(get_current_admin)): async def api_list_files(username: str = Depends(get_current_admin)):
files_data = [] async with AsyncSessionLocal() as session:
artifacts = (await session.execute(select(Artifact).order_by(Artifact.created_at.desc()))).scalars().all()
# 1. Экспорты files_data = []
export_path = BASE_DIR / "exports" for a in artifacts:
if export_path.exists(): files_data.append({"id": a.id, "filename": a.filename, "file_type": a.file_type.upper(), "file_id": a.file_id, "created_at": fmt_local(a.created_at), "is_artifact": True})
for f in export_path.glob("*.json"): export_path = BASE_DIR / "exports"
files_data.append({ if export_path.exists():
"filename": f.name, for f in export_path.glob("*.json"):
"file_type": "EXPORT", files_data.append({"filename": f.name, "file_type": "EXPORT", "size": f"{round(f.stat().st_size / 1024, 1)} KB", "path": f"/exports/{f.name}", "is_artifact": False})
"size": f"{round(f.stat().st_size / 1024, 1)} KB", log_path = BASE_DIR / "logs"
"path": f"/exports/{f.name}" 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}", "is_artifact": False})
# 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}) return JSONResponse({"success": True, "files": files_data})
@app.post("/api/files/upload")
async def api_upload_artifact(file: UploadFile = File(...), username: str = Depends(get_current_admin)):
try:
content = await file.read()
file_id, file_type = await upload_to_tg(content, file.filename, file.content_type)
async with AsyncSessionLocal() as db_session:
new_artifact = Artifact(filename=file.filename, file_type=file_type, file_id=file_id)
db_session.add(new_artifact)
await db_session.commit()
return JSONResponse({"success": True, "file_id": file_id, "filename": file.filename})
except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@app.get("/api/files/download/logs/{filename}") @app.get("/api/files/download/logs/{filename}")
async def download_log(filename: str, username: str = Depends(get_current_admin)): async def download_log(filename: str, username: str = Depends(get_current_admin)):
filepath = BASE_DIR / "logs" / filename filepath = BASE_DIR / "logs" / filename
@ -426,7 +503,6 @@ async def api_export_json(username: str = Depends(get_current_admin)):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
exporter = ChatExporter(session) exporter = ChatExporter(session)
result = await exporter.run_full_export() result = await exporter.run_full_export()
if not result.get('success'): return JSONResponse(status_code=500, content=result)
return JSONResponse(result) return JSONResponse(result)
except Exception as e: except Exception as e:
return JSONResponse(status_code=500, content={"success": False, "error": str(e)}) return JSONResponse(status_code=500, content={"success": False, "error": str(e)})
@ -446,7 +522,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
audit_data = (await session.execute(select(EmailAudit).order_by(EmailAudit.last_email_date.desc()))).scalars().all() 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() last_log = (await session.execute(select(EmailExportLog).order_by(EmailExportLog.start_time.desc()).limit(1))).scalar_one_or_none()
latest_emails = [] latest_emails = []
all_folder = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "all" all_folder = BASE_DIR / "services" / "email_auditor" / "exported_emails" / "all"
if all_folder.exists(): if all_folder.exists():
@ -454,14 +529,9 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
for f in files: for f in files:
match = re.match(r'(\d{4}-\d{2}-\d{2})_([^_]+)_(.*)', f.stem) 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)}) 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" 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 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})
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") @app.post("/api/emails/run")
async def api_run_email_audit(username: str = Depends(get_current_admin)): async def api_run_email_audit(username: str = Depends(get_current_admin)):

View file

@ -73,10 +73,35 @@
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small> <small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
</div> </div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-info">🖼️ Использовать готовое фото:</label>
<select class="form-select bg-dark text-info border-info" id="broadcastArtifactPhoto">
<option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label text-warning">📄 Использовать готовый документ:</label>
<select class="form-select bg-dark text-warning border-warning" id="broadcastArtifactDocument">
<option value="">-- Не выбрано (или загрузить новое) --</option>
{% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Картинка (необязательно)</label> <label class="form-label">Загрузить новую картинку (необязательно)</label>
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()"> <input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small> <small class="text-muted">Если выбран артефакт выше, загрузка файла будет проигнорирована.</small>
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div> <div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
</div> </div>
@ -312,6 +337,8 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
const text = document.getElementById('broadcastText').value; const text = document.getElementById('broadcastText').value;
const photo = document.getElementById('broadcastPhoto').files[0]; const photo = document.getElementById('broadcastPhoto').files[0];
const artifactPhotoId = document.getElementById('broadcastArtifactPhoto').value;
const artifactDocumentId = document.getElementById('broadcastArtifactDocument').value;
const recipients = document.getElementById('broadcastRecipients').value; const recipients = document.getElementById('broadcastRecipients').value;
const staircase = document.getElementById('broadcastStaircase').value; const staircase = document.getElementById('broadcastStaircase').value;
const resultDiv = document.getElementById('result'); const resultDiv = document.getElementById('result');
@ -340,7 +367,13 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
formData.append('has_read_button', 'true'); formData.append('has_read_button', 'true');
formData.append('track_reads', 'true'); formData.append('track_reads', 'true');
if (photo) { if (artifactPhotoId) {
formData.append('artifact_photo_id', artifactPhotoId);
}
if (artifactDocumentId) {
formData.append('artifact_document_id', artifactDocumentId);
}
if (photo && !artifactPhotoId) {
formData.append('photo', photo); formData.append('photo', photo);
} }

View file

@ -1,17 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Файловое хранилище - LKM37{% endblock %} {% block title %}Менеджер артефактов - LKM37{% endblock %}
{% block nav_files %}active{% endblock %} {% block nav_files %}active{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);"> <h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
>_ ХРАНИЛИЩЕ_АРТЕФАКТОВ (FILES) >_ ЦЕНТРАЛЬНЫЙ_АРХИВАННЫХ (FILES_CORE)
</h2> </h2>
<div class="card mb-4"> <!-- Секция загрузки артефактов -->
<div class="card-header border-success d-flex justify-content-between align-items-center"> <div class="row mb-4">
<span>СПИСОКОСТУПНЫХАЙЛОВ</span> <div class="col-md-12">
<button class="btn btn-outline-success btn-sm" onclick="loadFiles()"> <div class="card bg-black border-success shadow-sm">
<div class="card-body">
<h5 class="text-success mb-3"><i class="bi bi-cloud-upload"></i> РЕГИСТРАЦИЯ НОВОГО АРТЕФАКТА В TELEGRAM</h5>
<p class="small text-muted mb-3">Загрузите файл сюда, и бот отправит его вам в личку, чтобы получить постоянный <code>file_id</code>. Вы сможете использовать этот ID в рассылках и постах многократно.</p>
<form id="uploadForm" class="row g-3">
<div class="col-md-9">
<input type="file" id="fileInput" class="form-control bg-dark border-success text-success">
</div>
<div class="col-md-3">
<button type="button" id="uploadBtn" onclick="uploadFile()" class="btn btn-matrix w-100">
<span id="uploadSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
ЗАГРУЗИТЬ
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- Таблица артефактов -->
<div class="card mb-4 border-info">
<div class="card-header border-info bg-info bg-opacity-10 d-flex justify-content-between align-items-center">
<span class="text-info fw-bold">💠 АРТЕФАКТЫ TELEGRAM (ДЛЯ РАССЫЛОК)</span>
<button class="btn btn-outline-info btn-sm" onclick="loadFiles()">
<i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ <i class="bi bi-arrow-clockwise"></i> ОБНОВИТЬ
</button> </button>
</div> </div>
@ -19,37 +43,41 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-dark table-hover mb-0"> <table class="table table-dark table-hover mb-0">
<thead> <thead>
<tr class="text-success opacity-50" style="font-size: 0.8em;"> <tr class="text-info opacity-75" style="font-size: 0.8em;">
<th>ИМЯ ФАЙЛА</th> <th>ИМЯ ФАЙЛА</th>
<th>КАТЕГОРИЯ</th> <th>ТИП</th>
<th>РАЗМЕР</th> <th>TELEGRAM_FILE_ID</th>
<th class="text-end">ДЕЙСТВИЯ</th> <th class="text-end">ДЕЙСТВИЯ</th>
</tr> </tr>
</thead> </thead>
<tbody id="filesTable"> <tbody id="artifactsTable">
<tr> <!-- Заполняется через API -->
<td colspan="4" class="text-center p-5">
<div class="spinner-border text-success" role="status"></div>
<div class="mt-2">Сканирование секторов...</div>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> </div>
<div class="row"> <!-- Таблица системных файлов -->
<div class="col-md-6"> <div class="card border-secondary">
<div class="card border-info"> <div class="card-header border-secondary bg-secondary bg-opacity-10">
<div class="card-header border-info text-info">ИНФОРМАЦИЯ</div> <span class="text-secondary fw-bold">📁 СИСТЕМНЫЕ ФАЙЛЫ (ЛОГИ И ЭКСПОРТЫ)</span>
<div class="card-body small"> </div>
<ul class="mb-0"> <div class="card-body p-0">
<li>Категория <b>EXPORT</b>: JSON-выгрузки истории чата.</li> <div class="table-responsive">
<li>Категория <b>LOG</b>: Системные логи бота и веб-панели.</li> <table class="table table-dark table-hover mb-0">
<li>Все файлы доступны для прямого скачивания на локальный хост.</li> <thead>
</ul> <tr class="text-muted" style="font-size: 0.8em;">
</div> <th>ИМЯ ФАЙЛА</th>
<th>КАТЕГОРИЯ</th>
<th>РАЗМЕР</th>
<th class="text-end">ДЕЙСТВИЯ</th>
</tr>
</thead>
<tbody id="systemFilesTable">
<!-- Заполняется через API -->
</tbody>
</table>
</div> </div>
</div> </div>
</div> </div>
@ -60,36 +88,104 @@
document.addEventListener('DOMContentLoaded', loadFiles); document.addEventListener('DOMContentLoaded', loadFiles);
async function loadFiles() { async function loadFiles() {
const table = document.getElementById('filesTable'); const artifactsTable = document.getElementById('artifactsTable');
const systemFilesTable = document.getElementById('systemFilesTable');
try { try {
const response = await fetch('/api/files/list'); const response = await fetch('/api/files/list');
const result = await response.json(); const result = await response.json();
if (!result.success) throw new Error(result.error); if (!result.success) throw new Error(result.error);
table.innerHTML = ''; artifactsTable.innerHTML = '';
if (result.files.length === 0) { systemFilesTable.innerHTML = '';
table.innerHTML = '<tr><td colspan="4" class="text-center p-4">Файлы не найдены</td></tr>';
return; const artifacts = result.files.filter(f => f.is_artifact);
const systemFiles = result.files.filter(f => !f.is_artifact);
if (artifacts.length === 0) {
artifactsTable.innerHTML = '<tr><td colspan="4" class="text-center p-3 text-muted">Артефакты не зарегистрированы</td></tr>';
} else {
artifacts.forEach(f => {
artifactsTable.innerHTML += `
<tr>
<td class="font-monospace text-info">${f.filename}</td>
<td><span class="badge bg-info text-dark">${f.file_type}</span></td>
<td><code class="text-success small" style="word-break: break-all;">${f.file_id}</code></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-info" onclick="copyId('${f.file_id}')">
<i class="bi bi-copy"></i> ID
</button>
</td>
</tr>
`;
});
} }
result.files.forEach(f => { if (systemFiles.length === 0) {
const badgeClass = f.file_type === 'EXPORT' ? 'bg-primary' : 'bg-secondary'; systemFilesTable.innerHTML = '<tr><td colspan="4" class="text-center p-3 text-muted">Файлы не найдены</td></tr>';
table.innerHTML += ` } else {
<tr> systemFiles.forEach(f => {
<td class="font-monospace">${f.filename}</td> systemFilesTable.innerHTML += `
<td><span class="badge ${badgeClass}">${f.file_type}</span></td> <tr>
<td class="text-muted">${f.size}</td> <td class="font-monospace">${f.filename}</td>
<td class="text-end"> <td><span class="badge bg-secondary">${f.file_type}</span></td>
<a href="${f.path}" class="btn btn-sm btn-matrix" download> <td class="text-muted">${f.size}</td>
<i class="bi bi-download"></i> СКАЧАТЬ <td class="text-end">
</a> <a href="${f.path}" class="btn btn-sm btn-outline-secondary" download>
</td> <i class="bi bi-download"></i>
</tr> </a>
`; </td>
}); </tr>
`;
});
}
} catch (error) { } catch (error) {
table.innerHTML = `<tr><td colspan="4" class="text-center text-danger p-4">Ошибка: ${error.message}</td></tr>`; console.error(error);
}
}
function copyId(id) {
navigator.clipboard.writeText(id);
const toast = document.createElement('div');
toast.className = 'position-fixed bottom-0 end-0 p-3';
toast.style.zIndex = '11';
toast.innerHTML = `<div class="alert alert-success">FILE_ID скопирован!</div>`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2000);
}
async function uploadFile() {
const fileInput = document.getElementById('fileInput');
const btn = document.getElementById('uploadBtn');
const spinner = document.getElementById('uploadSpinner');
if (!fileInput.files[0]) return alert('Выберите файл для загрузки');
btn.disabled = true;
spinner.classList.remove('d-none');
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/files/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert('Файл успешно зарегистрирован в Telegram!\nFILE_ID: ' + result.file_id);
location.reload();
} else {
alert('Ошибка при регистрации: ' + result.error);
}
} catch (error) {
alert('Сетевая ошибка: ' + error.message);
} finally {
btn.disabled = false;
spinner.classList.add('d-none');
} }
} }
</script> </script>

View file

@ -28,21 +28,42 @@
<small class="text-muted">Поддерживается HTML: &lt;b&gt;жирный&lt;/b&gt;, &lt;i&gt;курсив&lt;/i&gt;, &lt;emoji&gt;</small> <small class="text-muted">Поддерживается HTML: &lt;b&gt;жирный&lt;/b&gt;, &lt;i&gt;курсив&lt;/i&gt;, &lt;emoji&gt;</small>
</div> </div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label text-info">🖼️ Готовое фото (Артефакт):</label>
<select class="form-select bg-dark text-info border-info" id="artifactPhotoId">
<option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'photo' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label text-warning">📄 Готовый документ (Артефакт):</label>
<select class="form-select bg-dark text-warning border-warning" id="artifactDocumentId">
<option value="">-- Не выбрано --</option>
{% for a in artifacts if a.file_type == 'document' %}
<option value="{{ a.file_id }}">{{ a.filename }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Картинка (необязательно)</label> <label class="form-label">Загрузить новую картинку (необязательно)</label>
<input type="file" class="form-control" id="postPhoto" accept="image/*" <input type="file" class="form-control" id="postPhoto" accept="image/*"
onchange="previewImage()"> onchange="previewImage()">
<small class="text-muted">JPG, PNG до 5MB</small> <small class="text-muted">Если выбран артефакт фото выше, загрузка будет проигнорирована.</small>
<div id="preview"> <div id="preview">
<img id="previewImg" alt="Предпросмотр"> <img id="previewImg" alt="Предпросмотр">
</div> </div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">📄 Документ (необязательно)</label> <label class="form-label">📄 Загрузить новый документ (необязательно)</label>
<input type="file" class="form-control" id="postDocument" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar" <input type="file" class="form-control" id="postDocument" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
onchange="previewDocument()"> onchange="previewDocument()">
<small class="text-muted">PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT, ZIP, RAR до 50MB</small> <small class="text-muted">Если выбран артефакт документа выше, загрузка будет проигнорирована.</small>
<div id="documentPreview" class="mt-2" style="display:none;"> <div id="documentPreview" class="mt-2" style="display:none;">
<span class="badge bg-success">📄 <span id="documentName"></span></span> <span class="badge bg-success">📄 <span id="documentName"></span></span>
<button type="button" class="btn btn-sm btn-danger ms-2" onclick="clearDocument()">✕ Удалить</button> <button type="button" class="btn btn-sm btn-danger ms-2" onclick="clearDocument()">✕ Удалить</button>
@ -180,6 +201,8 @@
const text = document.getElementById('postText').value; const text = document.getElementById('postText').value;
const photo = document.getElementById('postPhoto').files[0]; const photo = document.getElementById('postPhoto').files[0];
const artifactPhotoId = document.getElementById('artifactPhotoId').value;
const artifactDocumentId = document.getElementById('artifactDocumentId').value;
const topic = document.getElementById('postTopic').value; const topic = document.getElementById('postTopic').value;
const recipients = document.getElementById('postRecipients').value; const recipients = document.getElementById('postRecipients').value;
const date = document.getElementById('postDate').value; const date = document.getElementById('postDate').value;
@ -203,11 +226,18 @@
formData.append('topic_name', topic); formData.append('topic_name', topic);
formData.append('recipients', recipients); formData.append('recipients', recipients);
formData.append('scheduled_time', scheduledTime); formData.append('scheduled_time', scheduledTime);
if (photo) {
if (artifactPhotoId) {
formData.append('artifact_photo_id', artifactPhotoId);
}
if (artifactDocumentId) {
formData.append('artifact_document_id', artifactDocumentId);
}
if (photo && !artifactPhotoId) {
formData.append('photo', photo); formData.append('photo', photo);
} }
const doc = document.getElementById('postDocument').files[0]; const doc = document.getElementById('postDocument').files[0];
if (doc) { if (doc && !artifactDocumentId) {
formData.append('document', doc); formData.append('document', doc);
} }