Add source

This commit is contained in:
2025-12-04 00:12:56 +03:00
parent b75875df5e
commit 0cb7045e7a
75 changed files with 9055 additions and 0 deletions

4
web/admin/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
Web site admin panel
"""

859
web/admin/routes.py Normal file
View File

@@ -0,0 +1,859 @@
"""
Admin panel routes
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Form, status
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from web.utils.auth import get_current_user, verify_web_user, create_session, delete_session, is_owner_web
from web.utils.database import get_db
from web.utils.otp import verify_otp_code, get_user_by_identifier, create_otp_code
from web.utils.csrf import verify_csrf, get_csrf_token
from web.admin import views
from shared.database.models import User, Task
from web.admin.user_manager_web import add_user_web, remove_user_web, block_user_web, unblock_user_web, add_admin_web, remove_admin_web
from bot.modules.task_scheduler.queue import task_queue, Task as QueueTask, TaskStatus
from bot.modules.media_loader.ytdlp import get_media_info
from bot.modules.access_control.auth import is_authorized
from web.utils.bot_client import send_otp_to_user
from shared.config import settings
import logging
import time
import asyncio
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="web/admin/templates")
async def get_csrf_token_for_template(request: Request) -> Optional[str]:
"""
Get CSRF token for template.
Args:
request: FastAPI Request object
Returns:
CSRF token or None
"""
from web.utils.csrf import get_csrf_token, generate_csrf_token
from web.utils.auth import get_session
token = await get_csrf_token(request)
if not token:
# Generate new token
token = generate_csrf_token()
session_id = request.cookies.get("session_id")
if session_id:
session_data = await get_session(session_id)
if session_data:
session_data["csrf_token"] = token
return token
# ==================== Authentication ====================
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Login page"""
csrf_token = await get_csrf_token_for_template(request)
return templates.TemplateResponse("login.html", {
"request": request,
"csrf_token": csrf_token
})
@router.post("/api/otp/request")
async def request_otp(
request: Request,
identifier: str = Form(...),
csrf_token: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db)
):
"""API: Request OTP code by ID or username"""
# CSRF token check (softer for unauthorized users)
try:
await verify_csrf(request, csrf_token)
except HTTPException:
# For OTP request allow without CSRF (user not yet authorized)
# But check Origin/Referer for basic protection
origin = request.headers.get("Origin")
referer = request.headers.get("Referer")
if not origin and not referer:
logger.warning(f"OTP request without Origin/Referer from IP {request.client.host if request.client else 'unknown'}")
try:
user_id = None
# Try to find user by identifier
user = await get_user_by_identifier(identifier, db)
if user:
user_id = user.user_id
else:
# If user not found in database, try to determine user_id from identifier
try:
user_id = int(identifier)
except ValueError:
# This is username, try to find via Telegram API
try:
from bot.modules.task_scheduler.executor import get_app_client
app_client = get_app_client()
if app_client:
username = identifier.lstrip('@')
chat = await app_client.get_chat(username)
if chat and hasattr(chat, 'id'):
user_id = chat.id
logger.info(f"Найден пользователь {user_id} по username {username} через Telegram API")
except Exception as e:
logger.debug(f"Не удалось найти пользователя по username {identifier}: {e}")
if not user_id:
# User not found in database or via Telegram API
return JSONResponse(
{"success": False, "message": "Пользователь не найден. Убедитесь, что вы используете правильный User ID или username. Для первого входа используйте User ID."},
status_code=404
)
# Check authorization (this can work even if user doesn't exist in database)
if not await is_authorized(user_id):
return JSONResponse(
{"success": False, "message": "У вас нет доступа к боту. Обратитесь к администратору."},
status_code=403
)
# If user is authorized but doesn't exist in DB - create them
if not user:
from shared.database.models import User
from datetime import datetime
from bot.utils.user_info_updater import update_user_info_from_telegram
from sqlalchemy.exc import IntegrityError
# Check if user already exists (race condition protection)
existing_user = await db.get(User, user_id)
if existing_user:
user = existing_user
else:
# Create user with basic information
user = User(
user_id=user_id,
username=None,
first_name=None,
last_name=None,
is_admin=False,
is_blocked=False,
created_at=datetime.utcnow()
)
try:
db.add(user)
await db.commit()
logger.info(f"Automatically created user {user_id} on OTP request")
except IntegrityError:
# User was created by another request, fetch it
await db.rollback()
user = await db.get(User, user_id)
if not user:
logger.error(f"Failed to create or fetch user {user_id}")
return JSONResponse(
{"success": False, "message": "Ошибка при создании пользователя"},
status_code=500
)
logger.info(f"User {user_id} already exists, using existing record")
# Try to get information from Telegram API (async, doesn't block response)
try:
await update_user_info_from_telegram(user_id, db_session=db)
except Exception as e:
logger.warning(f"Failed to get user {user_id} information from Telegram: {e}")
# Continue working, information will be updated by background task later
# Generate OTP code
code = await create_otp_code(user_id, db)
if not code:
return JSONResponse(
{"success": False, "message": "Не удалось создать код. Попробуйте позже."},
status_code=500
)
# Send code to user in Telegram
sent = await send_otp_to_user(user_id, code)
if not sent:
# If failed to send, return warning
logger.warning(f"Failed to send OTP to user {user_id}, but code created")
return JSONResponse({
"success": True,
"message": f"Код создан: **{code}**. Не удалось отправить в Telegram (бот может быть не запущен). Используйте этот код для входа.",
"code": code # Return code in response for debugging
})
return JSONResponse({
"success": True,
"message": "Код отправлен вам в Telegram. Проверьте сообщения от бота."
})
except Exception as e:
logger.error(f"Error requesting OTP: {e}", exc_info=True)
return JSONResponse(
{"success": False, "message": f"Ошибка при создании кода: {str(e)}"},
status_code=500
)
@router.post("/login")
async def login(
request: Request,
otp_code: str = Form(None),
user_id: int = Form(None),
db: AsyncSession = Depends(get_db)
):
"""Handle login via OTP or direct user_id (for admins)"""
# Get IP address for rate limiting
client_ip = request.client.host if request.client else None
# If OTP code provided
if otp_code:
user_id_from_otp = await verify_otp_code(otp_code, db, ip_address=client_ip)
if not user_id_from_otp:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Неверный или истекший код"},
status_code=status.HTTP_401_UNAUTHORIZED
)
user_id = user_id_from_otp
# If user_id provided directly (alternative login method)
if user_id:
# Check user authorization
if not await is_authorized(user_id):
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "У вас нет доступа к боту. Обратитесь к администратору."},
status_code=status.HTTP_403_FORBIDDEN
)
# If user is authorized but doesn't exist in DB - create them
existing_user = await db.get(User, user_id)
if not existing_user:
from datetime import datetime
from bot.utils.user_info_updater import update_user_info_from_telegram
from sqlalchemy.exc import IntegrityError
# Create user with basic information
new_user = User(
user_id=user_id,
username=None,
first_name=None,
last_name=None,
is_admin=False,
is_blocked=False,
created_at=datetime.utcnow()
)
try:
db.add(new_user)
await db.commit()
logger.info(f"Automatically created user {user_id} on login")
except IntegrityError:
# User was created by another request, fetch it
await db.rollback()
existing_user = await db.get(User, user_id)
if not existing_user:
logger.error(f"Failed to create or fetch user {user_id}")
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Ошибка при создании пользователя"},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR
)
logger.info(f"User {user_id} already exists, using existing record")
# Try to get information from Telegram API (async, doesn't block response)
try:
await update_user_info_from_telegram(user_id, db_session=db)
except Exception as e:
logger.warning(f"Failed to get user {user_id} information from Telegram: {e}")
# Continue working, information will be updated by background task later
else:
return templates.TemplateResponse(
"login.html",
{"request": request, "error": "Необходимо указать код или User ID"},
status_code=status.HTTP_400_BAD_REQUEST
)
# Create session
session_id = await create_session(user_id)
# Set cookie
response = RedirectResponse(url="/admin/", status_code=status.HTTP_302_FOUND)
response.set_cookie(
key="session_id",
value=session_id,
httponly=True,
secure=False, # Set True for HTTPS in production
samesite="lax",
max_age=86400 * 7 # 7 days
)
return response
@router.get("/logout")
async def logout(request: Request):
"""Logout"""
session_id = request.cookies.get("session_id")
if session_id:
await delete_session(session_id)
response = RedirectResponse(url="/admin/login", status_code=status.HTTP_302_FOUND)
response.delete_cookie("session_id")
return response
# ==================== Protected routes ====================
@router.get("/", response_class=HTMLResponse)
async def admin_dashboard(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Main admin page (dashboard)"""
try:
# Get statistics
users_stats = await views.get_users_stats(db)
tasks_stats = await views.get_tasks_stats(db)
# Get recent tasks
recent_tasks = await views.get_all_tasks(db, limit=10)
return templates.TemplateResponse(
"dashboard.html",
{
"request": request,
"current_user": current_user,
"users_stats": users_stats,
"tasks_stats": tasks_stats,
"recent_tasks": recent_tasks,
"is_owner": current_user.get("is_owner", False),
"csrf_token": await get_csrf_token_for_template(request)
}
)
except Exception as e:
logger.error(f"Ошибка при загрузке дашборда: {e}")
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
@router.get("/users", response_class=HTMLResponse)
async def users_page(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
search: Optional[str] = None
):
"""User management page"""
try:
users = await views.get_all_users(db, search=search)
is_owner = current_user.get("is_owner", False)
return templates.TemplateResponse(
"users.html",
{
"request": request,
"current_user": current_user,
"users": users,
"is_owner": is_owner,
"search": search or "",
"csrf_token": await get_csrf_token_for_template(request)
}
)
except Exception as e:
logger.error(f"Ошибка при загрузке пользователей: {e}")
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
@router.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
status_filter: Optional[str] = None,
user_id: Optional[int] = None
):
"""Task management page"""
try:
tasks = await views.get_all_tasks(
db,
status_filter=status_filter,
user_id_filter=user_id
)
return templates.TemplateResponse(
"tasks.html",
{
"request": request,
"current_user": current_user,
"tasks": tasks,
"status_filter": status_filter or "",
"user_id_filter": user_id,
"csrf_token": await get_csrf_token_for_template(request)
}
)
except Exception as e:
logger.error(f"Ошибка при загрузке задач: {e}")
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
@router.get("/create-task", response_class=HTMLResponse)
async def create_task_page(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""Create task page"""
try:
# Get user tasks to display limits
user_tasks = await views.get_all_tasks(db, user_id_filter=current_user["user_id"])
active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]]
# Get CSRF token for form
csrf_token = await get_csrf_token_for_template(request)
return templates.TemplateResponse(
"create_task.html",
{
"request": request,
"current_user": current_user,
"active_tasks_count": len(active_tasks),
"max_concurrent_tasks": settings.MAX_CONCURRENT_TASKS,
"max_file_size": settings.max_file_size_bytes,
"max_duration": settings.max_duration_minutes_int,
"csrf_token": csrf_token
}
)
except Exception as e:
logger.error(f"Ошибка при загрузке страницы создания задачи: {e}")
raise HTTPException(status_code=500, detail="Ошибка при загрузке данных")
# ==================== API Endpoints ====================
@router.post("/api/users/add")
async def api_add_user(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Add user"""
# CSRF token check
await verify_csrf(request, csrf_token)
try:
result = await add_user_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при добавлении пользователя: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при добавлении пользователя"},
status_code=500
)
@router.post("/api/users/remove")
async def api_remove_user(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Remove user"""
# CSRF token check
await verify_csrf(request, csrf_token)
try:
result = await remove_user_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при удалении пользователя: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при удалении пользователя"},
status_code=500
)
@router.post("/api/users/block")
async def api_block_user(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Block user"""
# CSRF token check
await verify_csrf(request, csrf_token)
try:
result = await block_user_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при блокировке пользователя: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при блокировке пользователя"},
status_code=500
)
@router.post("/api/users/unblock")
async def api_unblock_user(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Unblock user"""
# CSRF token check
await verify_csrf(request, csrf_token)
try:
result = await unblock_user_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при разблокировке пользователя: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при разблокировке пользователя"},
status_code=500
)
@router.post("/api/users/add-admin")
async def api_add_admin(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Add administrator (Owner only)"""
# CSRF token check
await verify_csrf(request, csrf_token)
if not current_user.get("is_owner", False):
return JSONResponse(
{"success": False, "message": "Только Owner может добавлять администраторов"},
status_code=403
)
try:
result = await add_admin_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при добавлении администратора: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при добавлении администратора"},
status_code=500
)
@router.post("/api/users/remove-admin")
async def api_remove_admin(
request: Request,
user_id: int = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Remove administrator (Owner only)"""
# CSRF token check
await verify_csrf(request, csrf_token)
if not current_user.get("is_owner", False):
return JSONResponse(
{"success": False, "message": "Только Owner может удалять администраторов"},
status_code=403
)
try:
result = await remove_admin_web(user_id, db)
if result["success"]:
return JSONResponse({"success": True, "message": result["message"]})
else:
return JSONResponse(
{"success": False, "message": result["message"]},
status_code=400
)
except Exception as e:
logger.error(f"Ошибка при удалении администратора: {e}")
return JSONResponse(
{"success": False, "message": "Ошибка при удалении администратора"},
status_code=500
)
@router.post("/api/tasks/create")
async def api_create_task(
request: Request,
url: str = Form(...),
csrf_token: Optional[str] = Form(None),
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Create download task"""
# CSRF token check
try:
await verify_csrf(request, csrf_token)
except HTTPException as e:
logger.warning(f"CSRF check failed for task creation: {e.detail}")
return JSONResponse(
{"success": False, "message": "Невалидный CSRF токен"},
status_code=403
)
from bot.modules.task_scheduler.executor import task_executor, set_app_client
from bot.modules.access_control.auth import is_authorized
from bot.utils.helpers import is_valid_url
user_id = current_user["user_id"]
# URL validation
if not is_valid_url(url):
return JSONResponse(
{"success": False, "message": "Некорректный или небезопасный URL"},
status_code=400
)
# Check authorization
if not await is_authorized(user_id):
return JSONResponse(
{"success": False, "message": "У вас нет доступа к боту"},
status_code=403
)
try:
# Check concurrent tasks count
user_tasks = await views.get_all_tasks(db, user_id_filter=user_id)
active_tasks = [t for t in user_tasks if t.status in ["pending", "processing"]]
if len(active_tasks) >= settings.MAX_CONCURRENT_TASKS:
return JSONResponse(
{
"success": False,
"message": f"Превышен лимит одновременных задач ({settings.MAX_CONCURRENT_TASKS})"
},
status_code=400
)
# Duplicate URL check will be performed atomically in task_queue.add_task()
url_normalized = url.strip()
# Get media information to check limits
# If getting info takes too long, skip check
media_info = None
try:
from shared.config import settings
media_info = await asyncio.wait_for(
get_media_info(url, cookies_file=settings.COOKIES_FILE),
timeout=10.0
)
except asyncio.TimeoutError:
logger.warning(f"Timeout getting media info for URL: {url}")
media_info = None
except Exception as e:
logger.warning(f"Failed to get media info: {e}")
media_info = None
if media_info:
# Check duration
max_duration = settings.max_duration_minutes_int
if max_duration and media_info.get('duration'):
duration_minutes = media_info['duration'] / 60
if duration_minutes > max_duration:
return JSONResponse(
{
"success": False,
"message": f"Файл слишком длинный ({duration_minutes:.1f} мин). Максимум: {max_duration} мин."
},
status_code=400
)
# Generate unique task_id using UUID
from bot.utils.helpers import generate_unique_task_id
task_id = generate_unique_task_id()
# Check that such ID doesn't exist yet (in case of collision, though probability is extremely low)
existing_task = await task_queue.get_task_by_id(task_id)
max_retries = 10
retries = 0
while existing_task and retries < max_retries:
task_id = generate_unique_task_id()
existing_task = await task_queue.get_task_by_id(task_id)
retries += 1
if existing_task:
# If after 10 attempts still collision (extremely unlikely), return error
logger.error(f"Failed to generate unique task_id after {max_retries} attempts")
return JSONResponse(
{"success": False, "message": "Ошибка при создании задачи. Попробуйте позже."},
status_code=500
)
task = QueueTask(
id=task_id,
user_id=user_id,
task_type="download",
url=url_normalized,
status=TaskStatus.PENDING
)
# Save task to database BEFORE adding to queue (race condition fix)
try:
from shared.database.models import Task as DBTask
from shared.database.user_helpers import ensure_user_exists
from datetime import datetime
from sqlalchemy.exc import IntegrityError
# Ensure user exists before creating task
await ensure_user_exists(user_id, db)
db_task = DBTask(
id=task_id,
user_id=user_id,
task_type=task.task_type,
status=task.status.value,
url=task.url,
progress=0,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_task)
await db.commit()
logger.info(f"Task {task_id} saved to database")
except IntegrityError as e:
await db.rollback()
logger.error(f"IntegrityError saving task {task_id} to database (possibly duplicate ID): {e}", exc_info=True)
# Generate new task_id and retry
from bot.utils.helpers import generate_unique_task_id
task_id = generate_unique_task_id()
task.id = task_id
try:
from shared.database.user_helpers import ensure_user_exists
# Ensure user exists before creating task
await ensure_user_exists(user_id, db)
db_task = DBTask(
id=task_id,
user_id=user_id,
task_type=task.task_type,
status=task.status.value,
url=task.url,
progress=0,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(db_task)
await db.commit()
logger.info(f"Task {task_id} saved to database with new ID")
except Exception as e2:
await db.rollback()
logger.error(f"Error saving task {task_id} to database again: {e2}", exc_info=True)
return JSONResponse(
{"success": False, "message": f"Ошибка при создании задачи: {str(e2)}"},
status_code=500
)
except Exception as e:
await db.rollback()
logger.error(f"Error saving task {task_id} to database: {e}", exc_info=True)
return JSONResponse(
{"success": False, "message": f"Ошибка при создании задачи: {str(e)}"},
status_code=500
)
# Add to queue (with duplicate URL check) AFTER saving to database
success = await task_queue.add_task(task, check_duplicate_url=True)
if not success:
# If failed to add to queue, remove from database
try:
await db.delete(db_task)
await db.commit()
except Exception as e:
await db.rollback()
logger.error(f"Error deleting task {task_id} from database after failed queue addition: {e}")
return JSONResponse(
{
"success": False,
"message": "Задача с этим URL уже обрабатывается. Дождитесь завершения или отмените предыдущую задачу."
},
status_code=400
)
# Start executor if needed
from bot.modules.task_scheduler.executor import get_app_client
app_client = get_app_client()
if app_client:
set_app_client(app_client)
if not task_executor._running:
await task_executor.start()
return JSONResponse({
"success": True,
"message": "Задача успешно создана и добавлена в очередь",
"task_id": task_id
})
except Exception as e:
logger.error(f"Error creating task: {e}", exc_info=True)
return JSONResponse(
{"success": False, "message": f"Ошибка при создании задачи: {str(e)}"},
status_code=500
)
@router.get("/api/stats")
async def api_stats(
request: Request,
current_user: dict = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
"""API: Get statistics"""
try:
users_stats = await views.get_users_stats(db)
tasks_stats = await views.get_tasks_stats(db)
return JSONResponse({
"users": users_stats,
"tasks": tasks_stats
})
except Exception as e:
logger.error(f"Ошибка при получении статистики: {e}")
return JSONResponse(
{"error": "Ошибка при получении статистики"},
status_code=500
)

View File

@@ -0,0 +1,2 @@
# Директория для статических файлов (CSS, JS, изображения)

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}TGLoader Admin Panel{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
.sidebar {
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.sidebar .nav-link {
color: rgba(255, 255, 255, 0.8);
padding: 0.75rem 1rem;
margin: 0.25rem 0;
border-radius: 0.5rem;
transition: all 0.3s;
}
.sidebar .nav-link:hover, .sidebar .nav-link.active {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.main-content {
background-color: #f8f9fa;
min-height: 100vh;
}
.stat-card {
border: none;
border-radius: 1rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
transition: transform 0.3s;
}
.stat-card:hover {
transform: translateY(-5px);
}
.stat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<nav class="col-md-3 col-lg-2 sidebar p-3">
<div class="d-flex flex-column">
<h4 class="text-white mb-4">
<i class="bi bi-robot"></i> TGLoader
</h4>
<ul class="nav nav-pills flex-column">
<li class="nav-item">
<a class="nav-link {% if request.url.path == '/admin/' %}active{% endif %}" href="/admin/">
<i class="bi bi-speedometer2 me-2"></i> Дашборд
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/users' in request.url.path %}active{% endif %}" href="/admin/users">
<i class="bi bi-people me-2"></i> Пользователи
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/tasks' in request.url.path %}active{% endif %}" href="/admin/tasks">
<i class="bi bi-list-task me-2"></i> Задачи
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if '/admin/create-task' in request.url.path %}active{% endif %}" href="/admin/create-task">
<i class="bi bi-plus-circle me-2"></i> Создать задачу
</a>
</li>
</ul>
<hr class="text-white">
<div class="mt-auto">
<a class="nav-link" href="/admin/logout">
<i class="bi bi-box-arrow-right me-2"></i> Выход
</a>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="col-md-9 col-lg-10 main-content p-4">
{% block content %}{% endblock %}
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
<script>
// Глобальная функция для получения CSRF токена для AJAX запросов
function getCSRFToken() {
const tokenInput = document.querySelector('input[name="csrf_token"]');
return tokenInput ? tokenInput.value : '';
}
// Перехватываем все fetch запросы и добавляем CSRF токен
const originalFetch = window.fetch;
window.fetch = function(url, options = {}) {
if (options.method && options.method.toUpperCase() === 'POST') {
const token = getCSRFToken();
if (token) {
if (!options.headers) {
options.headers = {};
}
if (!(options.headers instanceof Headers)) {
options.headers['X-CSRF-Token'] = token;
}
}
}
return originalFetch(url, options);
};
</script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
{% extends "base.html" %}
{% block title %}Создать задачу - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-plus-circle me-2"></i>Создать задачу на загрузку</h1>
<a href="/admin/tasks" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-2"></i>Назад к задачам
</a>
</div>
<!-- Информация о лимитах -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Активные задачи</h6>
<h3>{{ active_tasks_count }} / {{ max_concurrent_tasks }}</h3>
</div>
</div>
</div>
{% if max_file_size %}
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Макс. размер файла</h6>
<h3>{{ (max_file_size / (1024*1024))|round(1) }} MB</h3>
</div>
</div>
</div>
{% endif %}
{% if max_duration %}
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h6 class="text-muted">Макс. длительность</h6>
<h3>{{ max_duration }} мин</h3>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Форма создания задачи -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Новая задача</h5>
</div>
<div class="card-body">
<form id="createTaskForm">
{% if csrf_token %}
<input type="hidden" name="csrf_token" id="csrf_token" value="{{ csrf_token }}">
{% endif %}
<div class="mb-3">
<label for="url" class="form-label">URL для загрузки</label>
<input type="url" class="form-control" id="url" name="url"
placeholder="https://www.youtube.com/watch?v=..." required>
<small class="form-text text-muted">
Поддерживаются YouTube, Instagram, прямые ссылки и другие платформы
</small>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-cloud-download me-2"></i>Создать задачу
</button>
</div>
</form>
</div>
</div>
{% block extra_js %}
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const cardBody = document.querySelector('.card-body');
cardBody.insertBefore(alertDiv, cardBody.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
let isSubmitting = false; // Флаг для предотвращения двойной отправки
document.getElementById('createTaskForm').addEventListener('submit', async (e) => {
e.preventDefault();
// Защита от двойной отправки
if (isSubmitting) {
return;
}
const form = e.target;
const formData = new FormData(form);
const submitBtn = form.querySelector('button[type="submit"]');
// Получаем CSRF токен из формы или мета-тега
const csrfTokenInput = document.getElementById('csrf_token');
const csrfToken = csrfTokenInput ? csrfTokenInput.value :
(document.querySelector('meta[name="csrf-token"]')?.content || '');
isSubmitting = true;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Создание...';
try {
const response = await fetch('/admin/api/tasks/create', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': csrfToken || ''
}
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => {
window.location.href = '/admin/tasks';
}, 1500);
} else {
showAlert(data.message, 'danger');
isSubmitting = false;
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
}
} catch (error) {
showAlert('Ошибка при создании задачи', 'danger');
isSubmitting = false;
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-cloud-download me-2"></i>Создать задачу';
}
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}Дашборд - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-speedometer2 me-2"></i>Дашборд</h1>
<div>
<span class="badge bg-secondary">User ID: {{ current_user.user_id }}</span>
{% if is_owner %}
<span class="badge bg-danger ms-2">Owner</span>
{% else %}
<span class="badge bg-primary ms-2">Admin</span>
{% endif %}
</div>
</div>
<!-- Статистика пользователей -->
<div class="row mb-4">
<div class="col-md-12">
<h5 class="mb-3"><i class="bi bi-people me-2"></i>Пользователи</h5>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-primary text-white me-3">
<i class="bi bi-people"></i>
</div>
<div>
<h6 class="text-muted mb-0">Всего</h6>
<h3 class="mb-0">{{ users_stats.total }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-success text-white me-3">
<i class="bi bi-shield-check"></i>
</div>
<div>
<h6 class="text-muted mb-0">Администраторы</h6>
<h3 class="mb-0">{{ users_stats.admins }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-danger text-white me-3">
<i class="bi bi-ban"></i>
</div>
<div>
<h6 class="text-muted mb-0">Заблокировано</h6>
<h3 class="mb-0">{{ users_stats.blocked }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-info text-white me-3">
<i class="bi bi-person-plus"></i>
</div>
<div>
<h6 class="text-muted mb-0">Новых (7 дней)</h6>
<h3 class="mb-0">{{ users_stats.new_week }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Статистика задач -->
<div class="row mb-4">
<div class="col-md-12">
<h5 class="mb-3"><i class="bi bi-list-task me-2"></i>Задачи</h5>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-primary text-white me-3">
<i class="bi bi-list-ul"></i>
</div>
<div>
<h6 class="text-muted mb-0">Всего</h6>
<h3 class="mb-0">{{ tasks_stats.total }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-warning text-white me-3">
<i class="bi bi-hourglass-split"></i>
</div>
<div>
<h6 class="text-muted mb-0">Активные</h6>
<h3 class="mb-0">{{ tasks_stats.active }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-success text-white me-3">
<i class="bi bi-check-circle"></i>
</div>
<div>
<h6 class="text-muted mb-0">Завершено</h6>
<h3 class="mb-0">{{ tasks_stats.completed }}</h3>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card stat-card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="stat-icon bg-danger text-white me-3">
<i class="bi bi-x-circle"></i>
</div>
<div>
<h6 class="text-muted mb-0">Ошибки</h6>
<h3 class="mb-0">{{ tasks_stats.failed }}</h3>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Последние задачи -->
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Последние задачи</h5>
</div>
<div class="card-body">
{% if recent_tasks %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>URL</th>
<th>Статус</th>
<th>Прогресс</th>
<th>Создано</th>
</tr>
</thead>
<tbody>
{% for task in recent_tasks %}
<tr>
<td>{{ task.id }}</td>
<td>
{% if task.user %}
{{ task.user.username or task.user.user_id }}
{% else %}
{{ task.user_id }}
{% endif %}
</td>
<td>
<small class="text-muted">
{{ task.url[:50] + '...' if task.url and task.url|length > 50 else (task.url or '-') }}
</small>
</td>
<td>
{% if task.status == 'completed' %}
<span class="badge bg-success">Завершено</span>
{% elif task.status == 'processing' %}
<span class="badge bg-warning">В процессе</span>
{% elif task.status == 'pending' %}
<span class="badge bg-info">Ожидание</span>
{% elif task.status == 'failed' %}
<span class="badge bg-danger">Ошибка</span>
{% elif task.status == 'cancelled' %}
<span class="badge bg-secondary">Отменено</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td>
<div class="progress" style="height: 20px; width: 100px;">
<div class="progress-bar" role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
</td>
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Нет задач</p>
{% endif %}
<div class="text-center mt-3">
<a href="/admin/tasks" class="btn btn-outline-primary">Все задачи</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,220 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - TGLoader Admin</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
border: none;
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 450px;
width: 100%;
}
.login-tabs {
border-bottom: 2px solid #dee2e6;
}
.nav-link {
color: #6c757d;
}
.nav-link.active {
color: #667eea;
border-bottom-color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card login-card">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-robot" style="font-size: 3rem; color: #667eea;"></i>
<h2 class="mt-3">TGLoader</h2>
<p class="text-muted">Вход в веб-интерфейс</p>
</div>
{% if error %}
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>{{ error }}
</div>
{% endif %}
<!-- Вкладки -->
<ul class="nav nav-tabs login-tabs mb-4" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="otp-tab" data-bs-toggle="tab"
data-bs-target="#otp-pane" type="button" role="tab">
<i class="bi bi-key me-2"></i>По коду
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-tab" data-bs-toggle="tab"
data-bs-target="#admin-pane" type="button" role="tab">
<i class="bi bi-shield-check me-2"></i>Админ (по ID)
</button>
</li>
</ul>
<div class="tab-content">
<!-- Вход по OTP коду -->
<div class="tab-pane fade show active" id="otp-pane" role="tabpanel">
<div class="mb-3">
<label for="identifier" class="form-label">Telegram User ID или Username</label>
<input type="text" class="form-control" id="identifier"
placeholder="Введите ваш User ID или @username">
<small class="form-text text-muted">
Код будет отправлен вам в Telegram
</small>
</div>
<button type="button" class="btn btn-outline-primary w-100 mb-3"
onclick="requestOTP()">
<i class="bi bi-send me-2"></i>Получить код
</button>
<div id="otp-input-group" style="display: none;">
<div class="mb-3">
<label for="otp_code" class="form-label">Одноразовый код</label>
<input type="text" class="form-control" id="otp_code" name="otp_code"
placeholder="Введите код из Telegram" maxlength="6">
<small class="form-text text-muted">
Код действителен 10 минут
</small>
</div>
<form method="POST" action="/admin/login" id="otp-form">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endif %}
<input type="hidden" name="otp_code" id="otp_code_hidden">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
</button>
</form>
</div>
<div class="mt-3 text-center">
<small class="text-muted">
Или используйте команду <code>/login</code> в боте для получения кода
</small>
</div>
</div>
<!-- Вход по User ID (альтернативный способ) -->
<div class="tab-pane fade" id="admin-pane" role="tabpanel">
<form method="POST" action="/admin/login">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% endif %}
<input type="hidden" name="user_id" id="admin_user_id">
<div class="mb-3">
<label for="admin_user_id_input" class="form-label">Telegram User ID</label>
<input type="number" class="form-control" id="admin_user_id_input"
placeholder="Введите ваш Telegram User ID" required>
<small class="form-text text-muted">
Альтернативный способ входа (без OTP кода)
</small>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-box-arrow-in-right me-2"></i>Войти
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
const cardBody = document.querySelector('.card-body');
cardBody.insertBefore(alertDiv, cardBody.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
async function requestOTP() {
const identifier = document.getElementById('identifier').value.trim();
if (!identifier) {
showAlert('Введите User ID или username', 'danger');
return;
}
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Отправка...';
try {
const formData = new FormData();
formData.append('identifier', identifier);
const response = await fetch('/admin/api/otp/request', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
document.getElementById('otp-input-group').style.display = 'block';
document.getElementById('otp_code').focus();
// Если код возвращен в ответе (не удалось отправить в Telegram)
if (data.code) {
document.getElementById('otp_code').value = data.code;
showAlert(`Код не был отправлен в Telegram. Используйте код: ${data.code}`, 'warning');
}
} else {
showAlert(data.message, 'danger');
}
} catch (error) {
showAlert('Ошибка при запросе кода', 'danger');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-send me-2"></i>Получить код';
}
}
// Обработка формы OTP
document.getElementById('otp-form').addEventListener('submit', function(e) {
const code = document.getElementById('otp_code').value.trim();
if (!code) {
e.preventDefault();
showAlert('Введите код', 'danger');
return;
}
document.getElementById('otp_code_hidden').value = code;
});
// Обработка формы админа
document.getElementById('admin_user_id_input').addEventListener('input', function(e) {
document.getElementById('admin_user_id').value = e.target.value;
});
// Enter для запроса OTP
document.getElementById('identifier').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
requestOTP();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Задачи - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-list-task me-2"></i>Управление задачами</h1>
</div>
<!-- Фильтры -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/admin/tasks" class="row g-3">
<div class="col-md-4">
<label class="form-label">Статус</label>
<select class="form-select" name="status_filter">
<option value="">Все</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидание</option>
<option value="processing" {% if status_filter == 'processing' %}selected{% endif %}>В процессе</option>
<option value="completed" {% if status_filter == 'completed' %}selected{% endif %}>Завершено</option>
<option value="failed" {% if status_filter == 'failed' %}selected{% endif %}>Ошибка</option>
<option value="cancelled" {% if status_filter == 'cancelled' %}selected{% endif %}>Отменено</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">User ID</label>
<input type="number" class="form-control" name="user_id"
placeholder="Фильтр по User ID"
value="{{ user_id_filter or '' }}">
</div>
<div class="col-md-4">
<label class="form-label">&nbsp;</label>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-funnel me-2"></i>Применить фильтры
</button>
</div>
</form>
</div>
</div>
<!-- Таблица задач -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Список задач</h5>
</div>
<div class="card-body">
{% if tasks %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>Тип</th>
<th>URL</th>
<th>Статус</th>
<th>Прогресс</th>
<th>Создано</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td><code>{{ task.id }}</code></td>
<td>
{% if task.user %}
<strong>{{ task.user.username or task.user.user_id }}</strong>
<br><small class="text-muted">ID: {{ task.user_id }}</small>
{% else %}
{{ task.user_id }}
{% endif %}
</td>
<td><span class="badge bg-info">{{ task.task_type }}</span></td>
<td>
{% if task.url %}
<a href="{{ task.url }}" target="_blank" class="text-decoration-none">
{{ task.url[:60] + '...' if task.url|length > 60 else task.url }}
<i class="bi bi-box-arrow-up-right ms-1"></i>
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if task.status == 'completed' %}
<span class="badge bg-success">Завершено</span>
{% elif task.status == 'processing' %}
<span class="badge bg-warning">В процессе</span>
{% elif task.status == 'pending' %}
<span class="badge bg-info">Ожидание</span>
{% elif task.status == 'failed' %}
<span class="badge bg-danger">Ошибка</span>
{% elif task.status == 'cancelled' %}
<span class="badge bg-secondary">Отменено</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<div class="progress me-2" style="height: 20px; width: 100px;">
<div class="progress-bar
{% if task.status == 'completed' %}bg-success
{% elif task.status == 'failed' %}bg-danger
{% elif task.status == 'processing' %}bg-warning
{% else %}bg-info{% endif %}"
role="progressbar"
style="width: {{ task.progress }}%"
aria-valuenow="{{ task.progress }}"
aria-valuemin="0"
aria-valuemax="100">
{{ task.progress }}%
</div>
</div>
</div>
</td>
<td><small>{{ task.created_at.strftime('%Y-%m-%d %H:%M') if task.created_at else '-' }}</small></td>
<td><small>{{ task.updated_at.strftime('%Y-%m-%d %H:%M') if task.updated_at else '-' }}</small></td>
</tr>
{% if task.error_message %}
<tr>
<td colspan="8">
<div class="alert alert-danger mb-0 py-2">
<small><strong>Ошибка:</strong> {{ task.error_message }}</small>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Задачи не найдены</p>
{% endif %}
</div>
</div>
{% block extra_js %}
<script>
// Автообновление каждые 5 секунд для активных задач
setInterval(() => {
const hasActiveTasks = Array.from(document.querySelectorAll('.badge')).some(
badge => badge.textContent.includes('В процессе') || badge.textContent.includes('Ожидание')
);
if (hasActiveTasks) {
location.reload();
}
}, 5000);
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,228 @@
{% extends "base.html" %}
{% block title %}Пользователи - TGLoader Admin{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="bi bi-people me-2"></i>Управление пользователями</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addUserModal">
<i class="bi bi-person-plus me-2"></i>Добавить пользователя
</button>
</div>
<!-- Поиск -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/admin/users" class="row g-3">
<div class="col-md-10">
<input type="text" class="form-control" name="search"
placeholder="Поиск по User ID или username..."
value="{{ search }}">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-search me-2"></i>Поиск
</button>
</div>
</form>
</div>
</div>
<!-- Таблица пользователей -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">Список пользователей</h5>
</div>
<div class="card-body">
{% if users %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>User ID</th>
<th>Username</th>
<th>Имя</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td><code>{{ user.user_id }}</code></td>
<td>@{{ user.username or '-' }}</td>
<td>{{ user.first_name or '-' }} {{ user.last_name or '' }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-primary">Администратор</span>
{% else %}
<span class="badge bg-secondary">Пользователь</span>
{% endif %}
</td>
<td>
{% if user.is_blocked %}
<span class="badge bg-danger">Заблокирован</span>
{% else %}
<span class="badge bg-success">Активен</span>
{% endif %}
</td>
<td><small>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</small></td>
<td>
<div class="btn-group btn-group-sm">
{% if not user.is_blocked %}
<button class="btn btn-outline-danger"
onclick="blockUser({{ user.user_id }})"
title="Заблокировать">
<i class="bi bi-ban"></i>
</button>
{% else %}
<button class="btn btn-outline-success"
onclick="unblockUser({{ user.user_id }})"
title="Разблокировать">
<i class="bi bi-check-circle"></i>
</button>
{% endif %}
{% if is_owner %}
{% if user.is_admin %}
<button class="btn btn-outline-warning"
onclick="removeAdmin({{ user.user_id }})"
title="Удалить из администраторов">
<i class="bi bi-shield-x"></i>
</button>
{% else %}
<button class="btn btn-outline-primary"
onclick="addAdmin({{ user.user_id }})"
title="Сделать администратором">
<i class="bi bi-shield-check"></i>
</button>
{% endif %}
{% endif %}
<button class="btn btn-outline-danger"
onclick="removeUser({{ user.user_id }})"
title="Удалить">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted text-center">Пользователи не найдены</p>
{% endif %}
</div>
</div>
<!-- Модальное окно добавления пользователя -->
<div class="modal fade" id="addUserModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Добавить пользователя</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="addUserForm">
{% if csrf_token %}
<input type="hidden" name="csrf_token" id="csrf_token_users" value="{{ csrf_token }}">
{% endif %}
<div class="modal-body">
<div class="mb-3">
<label for="new_user_id" class="form-label">Telegram User ID</label>
<input type="number" class="form-control" id="new_user_id" name="user_id" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-primary">Добавить</button>
</div>
</form>
</div>
</div>
</div>
{% block extra_js %}
<script>
function showAlert(message, type = 'success') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.insertBefore(alertDiv, document.body.firstChild);
setTimeout(() => alertDiv.remove(), 5000);
}
async function apiCall(url, formData) {
// Добавляем CSRF токен
const csrfToken = document.getElementById('csrf_token_users')?.value ||
document.querySelector('input[name="csrf_token"]')?.value;
if (csrfToken) {
formData.append('csrf_token', csrfToken);
}
const response = await fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': csrfToken || ''
}
});
const data = await response.json();
if (data.success) {
showAlert(data.message, 'success');
setTimeout(() => location.reload(), 1000);
} else {
showAlert(data.message, 'danger');
}
}
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
await apiCall('/admin/api/users/add', formData);
bootstrap.Modal.getInstance(document.getElementById('addUserModal')).hide();
});
function removeUser(userId) {
if (!confirm('Вы уверены, что хотите удалить этого пользователя?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/remove', formData);
}
function blockUser(userId) {
if (!confirm('Вы уверены, что хотите заблокировать этого пользователя?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/block', formData);
}
function unblockUser(userId) {
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/unblock', formData);
}
function addAdmin(userId) {
if (!confirm('Вы уверены, что хотите сделать этого пользователя администратором?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/add-admin', formData);
}
function removeAdmin(userId) {
if (!confirm('Вы уверены, что хотите удалить этого пользователя из администраторов?')) return;
const formData = new FormData();
formData.append('user_id', userId);
apiCall('/admin/api/users/remove-admin', formData);
}
</script>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,253 @@
"""
Wrappers for user management in web interface
"""
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.models import User
from shared.config import settings
import logging
logger = logging.getLogger(__name__)
async def add_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Add user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
# Check existence
existing_user = await db.get(User, user_id)
if existing_user:
return {
"success": False,
"message": f"Пользователь {user_id} уже существует"
}
# Create new user
user = User(
user_id=user_id,
is_admin=False,
is_blocked=False
)
db.add(user)
await db.commit()
logger.info(f"User {user_id} added via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно добавлен"
}
except Exception as e:
logger.error(f"Error adding user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при добавлении пользователя: {str(e)}"
}
async def remove_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Remove user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
# Cannot delete Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя удалить Owner"
}
await db.delete(user)
await db.commit()
logger.info(f"User {user_id} removed via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно удален"
}
except Exception as e:
logger.error(f"Error removing user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при удалении пользователя: {str(e)}"
}
async def block_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Block user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
# Cannot block Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя заблокировать Owner"
}
user.is_blocked = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} blocked via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно заблокирован"
}
except Exception as e:
logger.error(f"Error blocking user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при блокировке пользователя: {str(e)}"
}
async def unblock_user_web(user_id: int, db: AsyncSession) -> dict:
"""
Unblock user (for web interface)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
return {
"success": False,
"message": f"Пользователь {user_id} не найден"
}
user.is_blocked = False
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} unblocked via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно разблокирован"
}
except Exception as e:
logger.error(f"Error unblocking user: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при разблокировке пользователя: {str(e)}"
}
async def add_admin_web(user_id: int, db: AsyncSession) -> dict:
"""
Add administrator (for web interface, Owner only)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
user = await db.get(User, user_id)
if not user:
# Create user if doesn't exist
user = User(user_id=user_id, is_admin=True, is_blocked=False)
db.add(user)
else:
user.is_admin = True
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"User {user_id} assigned as administrator via web interface")
return {
"success": True,
"message": f"Пользователь {user_id} успешно назначен администратором"
}
except Exception as e:
logger.error(f"Error assigning administrator: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при назначении администратора: {str(e)}"
}
async def remove_admin_web(user_id: int, db: AsyncSession) -> dict:
"""
Remove administrator (for web interface, Owner only)
Args:
user_id: User ID
db: Database session
Returns:
Dictionary with operation result
"""
try:
# Cannot remove privileges from Owner
if user_id == settings.OWNER_ID:
return {
"success": False,
"message": "Нельзя снять права администратора у Owner"
}
user = await db.get(User, user_id)
if not user or not user.is_admin:
return {
"success": False,
"message": f"Пользователь {user_id} не является администратором"
}
user.is_admin = False
user.updated_at = datetime.utcnow()
await db.commit()
logger.info(f"Administrator privileges removed from user {user_id} via web interface")
return {
"success": True,
"message": f"Права администратора успешно сняты у пользователя {user_id}"
}
except Exception as e:
logger.error(f"Error removing administrator privileges: {e}")
await db.rollback()
return {
"success": False,
"message": f"Ошибка при снятии прав администратора: {str(e)}"
}

176
web/admin/views.py Normal file
View File

@@ -0,0 +1,176 @@
"""
Admin panel views - data retrieval logic
"""
from typing import List, Optional
from sqlalchemy import select, func, desc
from sqlalchemy.ext.asyncio import AsyncSession
from shared.database.models import User, Task
from datetime import datetime, timedelta
import logging
logger = logging.getLogger(__name__)
async def get_users_stats(session: AsyncSession) -> dict:
"""Get user statistics"""
try:
# Total user count
total_users_result = await session.execute(
select(func.count(User.user_id))
)
total_users = total_users_result.scalar() or 0
# Administrator count
admins_result = await session.execute(
select(func.count(User.user_id)).where(User.is_admin == True)
)
total_admins = admins_result.scalar() or 0
# Blocked user count
blocked_result = await session.execute(
select(func.count(User.user_id)).where(User.is_blocked == True)
)
blocked_users = blocked_result.scalar() or 0
# New users in last 7 days
week_ago = datetime.utcnow() - timedelta(days=7)
new_users_result = await session.execute(
select(func.count(User.user_id)).where(User.created_at >= week_ago)
)
new_users = new_users_result.scalar() or 0
return {
"total": total_users,
"admins": total_admins,
"blocked": blocked_users,
"new_week": new_users
}
except Exception as e:
logger.error(f"Error getting user statistics: {e}")
return {"total": 0, "admins": 0, "blocked": 0, "new_week": 0}
async def get_tasks_stats(session: AsyncSession) -> dict:
"""Get task statistics"""
try:
# Total task count
total_tasks_result = await session.execute(
select(func.count(Task.id))
)
total_tasks = total_tasks_result.scalar() or 0
# Active tasks (pending, processing)
active_tasks_result = await session.execute(
select(func.count(Task.id)).where(
Task.status.in_(["pending", "processing"])
)
)
active_tasks = active_tasks_result.scalar() or 0
# Completed tasks
completed_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.status == "completed")
)
completed_tasks = completed_tasks_result.scalar() or 0
# Failed tasks
failed_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.status == "failed")
)
failed_tasks = failed_tasks_result.scalar() or 0
# Tasks in last 24 hours
day_ago = datetime.utcnow() - timedelta(hours=24)
recent_tasks_result = await session.execute(
select(func.count(Task.id)).where(Task.created_at >= day_ago)
)
recent_tasks = recent_tasks_result.scalar() or 0
return {
"total": total_tasks,
"active": active_tasks,
"completed": completed_tasks,
"failed": failed_tasks,
"recent_24h": recent_tasks
}
except Exception as e:
logger.error(f"Error getting task statistics: {e}")
return {"total": 0, "active": 0, "completed": 0, "failed": 0, "recent_24h": 0}
async def get_all_users(
session: AsyncSession,
limit: int = 100,
offset: int = 0,
search: Optional[str] = None
) -> List[User]:
"""Get user list"""
try:
query = select(User)
if search:
# Search by username or user_id
try:
user_id = int(search)
query = query.where(User.user_id == user_id)
except ValueError:
query = query.where(User.username.ilike(f"%{search}%"))
query = query.order_by(desc(User.created_at)).limit(limit).offset(offset)
result = await session.execute(query)
return list(result.scalars().all())
except Exception as e:
logger.error(f"Error getting users: {e}")
return []
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
"""Get user by ID"""
try:
return await session.get(User, user_id)
except Exception as e:
logger.error(f"Error getting user: {e}")
return None
async def get_all_tasks(
session: AsyncSession,
limit: int = 100,
offset: int = 0,
status_filter: Optional[str] = None,
user_id_filter: Optional[int] = None
) -> List[Task]:
"""Get task list"""
try:
query = select(Task)
if status_filter:
query = query.where(Task.status == status_filter)
if user_id_filter:
query = query.where(Task.user_id == user_id_filter)
query = query.order_by(desc(Task.created_at)).limit(limit).offset(offset)
result = await session.execute(query)
tasks = list(result.scalars().all())
# Load related users
for task in tasks:
await session.refresh(task, ["user"])
return tasks
except Exception as e:
logger.error(f"Error getting tasks: {e}")
return []
async def get_task_by_id(session: AsyncSession, task_id: int) -> Optional[Task]:
"""Get task by ID"""
try:
task = await session.get(Task, task_id)
if task:
await session.refresh(task, ["user"])
return task
except Exception as e:
logger.error(f"Error getting task: {e}")
return None