v4.8: Восстановление Artifact Manager, поддержка фото+док в рассылках и постах, исправление API
This commit is contained in:
parent
05e5659204
commit
aadfc041b4
6 changed files with 431 additions and 162 deletions
26
database/migrate_artifacts.py
Normal file
26
database/migrate_artifacts.py
Normal 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())
|
||||
|
|
@ -792,3 +792,17 @@ class EmailExportLog(Base):
|
|||
status = Column(String(50)) # 'success', 'error', 'running'
|
||||
new_emails_count = Column(Integer, default=0)
|
||||
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})>"
|
||||
|
|
|
|||
280
web/app.py
280
web/app.py
|
|
@ -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
|
||||
- Smart Broadcasts (с отслеживанием прочтения)
|
||||
- Scheduled Posts (с фото/документами)
|
||||
- Smart Broadcasts (фото + док через Artifacts)
|
||||
- Scheduled Posts (фото + док через Artifacts)
|
||||
- Weekly Digests (v3.1)
|
||||
- Chat Exporter (JSON + Gitea Push)
|
||||
- Email Auditor (аудит переписки)
|
||||
- Artifact Manager (хранилище медиа ID)
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
|
|
@ -16,6 +17,7 @@ import os
|
|||
import sys
|
||||
import secrets
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
|
@ -39,7 +41,7 @@ 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
|
||||
ProfileHistory, Achievement, Thank, SpyLog, Artifact
|
||||
)
|
||||
|
||||
# ===== ЧАСОВОЙ ПОЯС УЛЬЯНОВСК (UTC+4) =====
|
||||
|
|
@ -61,7 +63,7 @@ def fmt_local(dt, fmt: str = '%d.%m.%Y %H:%M') -> str:
|
|||
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 = FastAPI(title="LKM37 MEGA Admin Panel", version="4.8.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
@ -89,6 +91,30 @@ def get_current_admin(credentials: HTTPBasicCredentials = Depends(security)):
|
|||
)
|
||||
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)
|
||||
# ============================================================================
|
||||
|
|
@ -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_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))
|
||||
"stats": {"total": total_count, "verified": verified_count, "unverified": total_count - verified_count, "ig": len(ig_user_ids)},
|
||||
"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")
|
||||
async def api_update_user(user_id: int, request: Request, username: str = Depends(get_current_admin)):
|
||||
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:
|
||||
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})
|
||||
|
||||
# ============================================================================
|
||||
# ВЕРИФИКАЦИЯ
|
||||
# ============================================================================
|
||||
|
|
@ -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 with AsyncSessionLocal() as session:
|
||||
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 = []
|
||||
for b in broadcasts:
|
||||
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,
|
||||
'read_percent': round((read_count / b.total_sent * 100), 1) if b.total_sent > 0 else 0,
|
||||
'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)
|
||||
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")
|
||||
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,
|
||||
"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)
|
||||
"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})
|
||||
|
||||
@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)
|
||||
# ============================================================================
|
||||
|
|
@ -309,19 +418,11 @@ async def api_list_scheduled_posts(username: str = Depends(get_current_admin)):
|
|||
@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
|
||||
})
|
||||
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)):
|
||||
|
|
@ -329,44 +430,18 @@ async def api_generate_digest(username: str = Depends(get_current_admin)):
|
|||
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'
|
||||
)
|
||||
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)
|
||||
# ФАЙЛЫ / АРТЕФАКТЫ (Artifact Manager)
|
||||
# ============================================================================
|
||||
|
||||
@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")
|
||||
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}"
|
||||
})
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
artifacts = (await session.execute(select(Artifact).order_by(Artifact.created_at.desc()))).scalars().all()
|
||||
files_data = []
|
||||
for a in artifacts:
|
||||
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})
|
||||
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}", "is_artifact": False})
|
||||
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}", "is_artifact": False})
|
||||
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}")
|
||||
async def download_log(filename: str, username: str = Depends(get_current_admin)):
|
||||
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:
|
||||
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)})
|
||||
|
|
@ -446,7 +522,6 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
|
|||
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():
|
||||
|
|
@ -454,14 +529,9 @@ async def emails_page(request: Request, username: str = Depends(get_current_admi
|
|||
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
|
||||
})
|
||||
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)):
|
||||
|
|
|
|||
|
|
@ -73,10 +73,35 @@
|
|||
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
|
||||
</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">
|
||||
<label class="form-label">Картинка (необязательно)</label>
|
||||
<label class="form-label">Загрузить новую картинку (необязательно)</label>
|
||||
<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>
|
||||
|
||||
|
|
@ -312,6 +337,8 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
|
|||
|
||||
const text = document.getElementById('broadcastText').value;
|
||||
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 staircase = document.getElementById('broadcastStaircase').value;
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
|
@ -340,7 +367,13 @@ document.getElementById('broadcastForm').addEventListener('submit', async (e) =>
|
|||
formData.append('has_read_button', '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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Файловое хранилище - LKM37{% endblock %}
|
||||
{% block title %}Менеджер артефактов - LKM37{% endblock %}
|
||||
{% block nav_files %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4" style="color: var(--matrix-green); text-shadow: 0 0 10px var(--matrix-green);">
|
||||
>_ ХРАНИЛИЩЕ_АРТЕФАКТОВ (FILES)
|
||||
>_ ЦЕНТРАЛЬНЫЙ_АРХИВ_ДАННЫХ (FILES_CORE)
|
||||
</h2>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header border-success d-flex justify-content-between align-items-center">
|
||||
<span>СПИСОК_ДОСТУПНЫХ_ФАЙЛОВ</span>
|
||||
<button class="btn btn-outline-success btn-sm" onclick="loadFiles()">
|
||||
<!-- Секция загрузки артефактов -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<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> ОБНОВИТЬ
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -19,37 +43,41 @@
|
|||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<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>TELEGRAM_FILE_ID</th>
|
||||
<th class="text-end">ДЕЙСТВИЯ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="filesTable">
|
||||
<tr>
|
||||
<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 id="artifactsTable">
|
||||
<!-- Заполняется через API -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-info">
|
||||
<div class="card-header border-info text-info">ИНФОРМАЦИЯ</div>
|
||||
<div class="card-body small">
|
||||
<ul class="mb-0">
|
||||
<li>Категория <b>EXPORT</b>: JSON-выгрузки истории чата.</li>
|
||||
<li>Категория <b>LOG</b>: Системные логи бота и веб-панели.</li>
|
||||
<li>Все файлы доступны для прямого скачивания на локальный хост.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Таблица системных файлов -->
|
||||
<div class="card border-secondary">
|
||||
<div class="card-header border-secondary bg-secondary bg-opacity-10">
|
||||
<span class="text-secondary fw-bold">📁 СИСТЕМНЫЕ ФАЙЛЫ (ЛОГИ И ЭКСПОРТЫ)</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-hover mb-0">
|
||||
<thead>
|
||||
<tr class="text-muted" style="font-size: 0.8em;">
|
||||
<th>ИМЯ ФАЙЛА</th>
|
||||
<th>КАТЕГОРИЯ</th>
|
||||
<th>РАЗМЕР</th>
|
||||
<th class="text-end">ДЕЙСТВИЯ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="systemFilesTable">
|
||||
<!-- Заполняется через API -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -60,36 +88,104 @@
|
|||
document.addEventListener('DOMContentLoaded', loadFiles);
|
||||
|
||||
async function loadFiles() {
|
||||
const table = document.getElementById('filesTable');
|
||||
const artifactsTable = document.getElementById('artifactsTable');
|
||||
const systemFilesTable = document.getElementById('systemFilesTable');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/files/list');
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) throw new Error(result.error);
|
||||
|
||||
table.innerHTML = '';
|
||||
if (result.files.length === 0) {
|
||||
table.innerHTML = '<tr><td colspan="4" class="text-center p-4">Файлы не найдены</td></tr>';
|
||||
return;
|
||||
artifactsTable.innerHTML = '';
|
||||
systemFilesTable.innerHTML = '';
|
||||
|
||||
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 => {
|
||||
const badgeClass = f.file_type === 'EXPORT' ? 'bg-primary' : 'bg-secondary';
|
||||
table.innerHTML += `
|
||||
<tr>
|
||||
<td class="font-monospace">${f.filename}</td>
|
||||
<td><span class="badge ${badgeClass}">${f.file_type}</span></td>
|
||||
<td class="text-muted">${f.size}</td>
|
||||
<td class="text-end">
|
||||
<a href="${f.path}" class="btn btn-sm btn-matrix" download>
|
||||
<i class="bi bi-download"></i> СКАЧАТЬ
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
if (systemFiles.length === 0) {
|
||||
systemFilesTable.innerHTML = '<tr><td colspan="4" class="text-center p-3 text-muted">Файлы не найдены</td></tr>';
|
||||
} else {
|
||||
systemFiles.forEach(f => {
|
||||
systemFilesTable.innerHTML += `
|
||||
<tr>
|
||||
<td class="font-monospace">${f.filename}</td>
|
||||
<td><span class="badge bg-secondary">${f.file_type}</span></td>
|
||||
<td class="text-muted">${f.size}</td>
|
||||
<td class="text-end">
|
||||
<a href="${f.path}" class="btn btn-sm btn-outline-secondary" download>
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
} 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>
|
||||
|
|
|
|||
|
|
@ -28,21 +28,42 @@
|
|||
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <emoji></small>
|
||||
</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">
|
||||
<label class="form-label">Картинка (необязательно)</label>
|
||||
<label class="form-label">Загрузить новую картинку (необязательно)</label>
|
||||
<input type="file" class="form-control" id="postPhoto" 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>
|
||||
|
||||
<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"
|
||||
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;">
|
||||
<span class="badge bg-success">📄 <span id="documentName"></span></span>
|
||||
<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 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 recipients = document.getElementById('postRecipients').value;
|
||||
const date = document.getElementById('postDate').value;
|
||||
|
|
@ -203,11 +226,18 @@
|
|||
formData.append('topic_name', topic);
|
||||
formData.append('recipients', recipients);
|
||||
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);
|
||||
}
|
||||
const doc = document.getElementById('postDocument').files[0];
|
||||
if (doc) {
|
||||
if (doc && !artifactDocumentId) {
|
||||
formData.append('document', doc);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue