Files
Site_IKP_zaglushka/server.py
T

1384 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys
import subprocess
import os
# Auto-install dependencies if missing
for package in ["flask", "requests", "openpyxl"]:
try:
__import__(package)
except ImportError:
print(f"Пакет '{package}' не найден. Устанавливаем...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", package])
print(f"Пакет '{package}' успешно установлен!")
except Exception as e:
print(f"Ошибка при автоматической установке '{package}': {e}")
sys.exit(1)
import json
import uuid
import smtplib
import requests
from requests.auth import HTTPBasicAuth
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
import ipaddress
# YooKassa official IP address ranges for webhook callbacks
YOOKASSA_ALLOWED_NETWORKS = [
ipaddress.ip_network("185.71.76.0/27"),
ipaddress.ip_network("185.71.77.0/27"),
ipaddress.ip_network("77.75.153.0/25"),
ipaddress.ip_network("77.75.154.128/25"),
ipaddress.ip_network("77.75.156.11/32"),
ipaddress.ip_network("77.75.156.35/32"),
ipaddress.ip_network("2a02:5180::/32"),
ipaddress.ip_network("2a02:5180:0:1509::/64"),
ipaddress.ip_network("2a02:5180:0:2655::/64"),
ipaddress.ip_network("2a02:5180:0:1533::/64"),
ipaddress.ip_network("2a02:5180:0:2669::/64"),
]
def is_yookassa_ip(ip_str: str) -> bool:
"""Check if the provided IP address belongs to YooKassa's official subnets."""
try:
ip = ipaddress.ip_address(ip_str)
return any(ip in net for net in YOOKASSA_ALLOWED_NETWORKS)
except ValueError:
return False
import openpyxl
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment, PatternFill
from flask import Flask, request, jsonify, redirect, url_for, session, render_template_string
# Import local modules
import config
from database import DatabaseManager
# Get root directory
ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
# Initialize SQLite database
db_path = os.path.join(ROOT_DIR, "bot_database.db")
db = DatabaseManager(db_path=db_path)
# Initialize Flask
# static_folder points to the root directory to serve the whole site!
app = Flask(__name__, static_folder=ROOT_DIR, static_url_path='')
app.secret_key = os.getenv("FLASK_SECRET_KEY", "ikp_stable_admin_session_key_secret_2026")
# Path to the Excel spreadsheet in the form directory
FILE_NAME = os.path.join(ROOT_DIR, "form", "registration_for_training.xlsx")
def init_excel():
"""Create the Excel file with styled headers if it doesn't exist."""
recreate = False
if os.path.exists(FILE_NAME):
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
# Recreate if it does not have the 'id_user' column as the first header
if ws.cell(row=1, column=1).value != "id_user":
recreate = True
except Exception:
recreate = True
if recreate:
try:
os.remove(FILE_NAME)
print("Обнаружена старая структура Excel файла. Пересоздаём с новыми колонками...")
except Exception as e:
print(f"Не удалось удалить старый Excel файл: {e}")
if not os.path.exists(FILE_NAME):
# Ensure form directory exists
os.makedirs(os.path.dirname(FILE_NAME), exist_ok=True)
wb = Workbook()
ws = wb.active
ws.title = "Заявки на обучение"
headers = [
"id_user",
"Имя",
"Номер телефона",
"E-mail",
"Ссылка на профиль ВКонтакте",
"Согласие с офертой",
"Согласие с политикой",
"Согласие на рассылку",
"Услуга",
"Сумма",
"Статус оплаты",
"ID платежа YooKassa",
"Дата регистрации",
"Дата оплаты"
]
ws.append(headers)
# Style header cells
header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1F4E78", end_color="1F4E78", fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
column_widths = [15, 20, 25, 25, 30, 15, 15, 15, 25, 15, 15, 30, 22, 22]
for i, width in enumerate(column_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
wb.save(FILE_NAME)
print(f"Создан новый файл Excel с поддержкой платежей: {FILE_NAME}")
def get_next_user_id(ws):
"""Generate the next unique user ID in the format id_001, id_002..."""
max_row = ws.max_row
if max_row <= 1:
return "id_001"
last_id_val = ws.cell(row=max_row, column=1).value
if not last_id_val or not str(last_id_val).startswith("id_"):
return f"id_{(max_row - 1):03d}"
try:
numeric_part = int(str(last_id_val).split("_")[1])
next_num = numeric_part + 1
return f"id_{next_num:03d}"
except (IndexError, ValueError):
return f"id_{max_row:03d}"
def add_registration_to_excel(reg_id, data):
"""Append a new form submission row into the Excel sheet."""
init_excel()
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
except Exception as e:
print(f"Ошибка загрузки Excel файла, пересоздаём: {e}")
init_excel()
wb = load_workbook(FILE_NAME)
ws = wb.active
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
row = [
reg_id,
data.get("name", "").strip(),
data.get("phone", "").strip(),
data.get("email", "").strip(),
data.get("vk", "").strip(),
"Да" if data.get("agreeOferta") else "Нет",
"Да" if data.get("agreePolicy") else "Нет",
"Да" if data.get("agreeNewsletter") else "Нет",
data.get("serviceName", "Разовая тренировка").strip(),
float(data.get("servicePrice", 870)),
"Не оплачено",
data.get("yookassaPaymentId", ""),
current_time,
""
]
ws.append(row)
new_row_index = ws.max_row
data_align_left = Alignment(horizontal="left", vertical="center")
data_align_center = Alignment(horizontal="center", vertical="center")
for col_idx in range(1, len(row) + 1):
cell = ws.cell(row=new_row_index, column=col_idx)
if col_idx in [2, 4, 5, 9]:
cell.alignment = data_align_left
else:
cell.alignment = data_align_center
wb.save(FILE_NAME)
def update_excel_payment_status(reg_id, status="Оплачено", paid_at=""):
"""Update payment status in the Excel file for a specific registration ID."""
init_excel()
if not os.path.exists(FILE_NAME):
return
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
found = False
for r in range(2, ws.max_row + 1):
if str(ws.cell(row=r, column=1).value) == str(reg_id):
ws.cell(row=r, column=11).value = status # Col 11: Статус оплаты
ws.cell(row=r, column=14).value = paid_at # Col 14: Дата оплаты
found = True
break
if found:
wb.save(FILE_NAME)
print(f"Статус оплаты заказа {reg_id} успешно обновлен в Excel на '{status}'")
except Exception as e:
print(f"Ошибка при обновлении статуса оплаты в Excel: {e}")
# ====================================================
# YooKassa REST API Functions
# ====================================================
def create_yookassa_payment(reg_id, amount, description, return_url, customer_email=None, customer_phone=None, customer_name=None):
shop_id = config.YOOKASSA_SHOP_ID
secret_key = config.YOOKASSA_SECRET_KEY
if not shop_id or not secret_key:
print("[YOOKASSA] Создание платежа пропущено: логин/пароль ЮKassa не настроены в .env")
return None
url = "https://api.yookassa.ru/v3/payments"
headers = {
"Idempotence-Key": str(uuid.uuid4()),
"Content-Type": "application/json"
}
payload = {
"amount": {
"value": f"{amount:.2f}",
"currency": "RUB"
},
"capture": True,
"confirmation": {
"type": "redirect",
"return_url": return_url
},
"description": description[:128],
"metadata": {
"registration_id": reg_id
}
}
# Add 54-FZ compliant receipt if customer contact is available
if customer_email or customer_phone:
clean_phone = None
if customer_phone:
clean_phone = "".join(filter(str.isdigit, customer_phone))
if clean_phone.startswith("8") and len(clean_phone) == 11:
clean_phone = "7" + clean_phone[1:]
customer = {}
if customer_name:
customer["full_name"] = customer_name[:256]
if customer_email:
customer["email"] = customer_email
if clean_phone:
customer["phone"] = clean_phone
payload["receipt"] = {
"customer": customer,
"tax_system_code": config.TAX_SYSTEM_CODE,
"items": [
{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": f"{amount:.2f}",
"currency": "RUB"
},
"vat_code": 1, # Без НДС
"payment_mode": "full_payment",
"payment_subject": "service"
}
]
}
try:
response = requests.post(url, json=payload, headers=headers, auth=HTTPBasicAuth(shop_id, secret_key), timeout=15)
if response.status_code == 200:
res_data = response.json()
payment_id = res_data.get("id")
confirmation_url = res_data.get("confirmation", {}).get("confirmation_url")
return {"payment_id": payment_id, "confirmation_url": confirmation_url}
else:
print(f"[YOOKASSA] Ошибка API ({response.status_code}): {response.text}")
return None
except Exception as e:
print(f"[YOOKASSA] Исключение при вызове API ЮKassa: {e}")
return None
def check_yookassa_payment_status(payment_id):
"""Retrieve payment status directly from YooKassa API."""
shop_id = config.YOOKASSA_SHOP_ID
secret_key = config.YOOKASSA_SECRET_KEY
if not shop_id or not secret_key or not payment_id:
return None
url = f"https://api.yookassa.ru/v3/payments/{payment_id}"
try:
response = requests.get(url, auth=HTTPBasicAuth(shop_id, secret_key), timeout=15)
if response.status_code == 200:
res_data = response.json()
return res_data.get("status") # succeeded, pending, canceled
else:
print(f"[YOOKASSA] Ошибка проверки платежа {payment_id}: {response.text}")
return None
except Exception as e:
print(f"[YOOKASSA] Исключение при проверке платежа: {e}")
return None
# ====================================================
# Notifications Logic
# ====================================================
try:
import vk_api
from vk_api.utils import get_random_id
except ImportError:
vk_api = None
def send_vk_operator_notification(reg_id, data, paid=False):
"""Send VK chat alert to operators about registrations."""
if not vk_api:
return
token = getattr(config, "VK_TOKEN", "")
operator_id = getattr(config, "OPERATOR_PEER_ID", 0)
if not token or token == "your_vk_community_token_here" or not operator_id:
return
try:
vk_session = vk_api.VkApi(token=token)
vk = vk_session.get_api()
status_emoji = "✅ [ОПЛАЧЕНО]" if paid else "⏳ [ОЖИДАЕТ ОПЛАТЫ]"
message = (
f"🌐 **Новая активность на сайте ИКП!**\n\n"
f"Статус: {status_emoji}\n"
)
vk.messages.send(
peer_id=operator_id,
message=message,
random_id=get_random_id()
)
except Exception as e:
print(f"[VK] Ошибка отправки уведомления: {e}")
def send_email_notification(reg_id, data, paid=False):
"""Send SMTP email notification."""
smtp_server = config.SMTP_SERVER
smtp_port = config.SMTP_PORT
smtp_user = config.SMTP_USER
smtp_password = config.SMTP_PASSWORD
smtp_from = config.SMTP_FROM or smtp_user
smtp_to_raw = config.SMTP_TO or smtp_user
smtp_to = [email.strip() for email in smtp_to_raw.split(',')]
print(f"[SMTP] Настройки: Server={smtp_server}, Port={smtp_port}, User={smtp_user}, To={smtp_to}")
if not smtp_server or not smtp_user or not smtp_password:
print("[SMTP] Пропущено: параметры SMTP не заполнены в .env")
return False
status_str = "УСПЕШНО ОПЛАЧЕН" if paid else "СОЗДАН (ОЖИДАЕТ ОПЛАТЫ)"
subject = f"Новый заказ на сайте ИКП #{reg_id} - {status_str}"
html_content = f"""
<html>
<body style="font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #333; line-height: 1.6; background-color: #f4f6f9; padding: 20px;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #e2e8f0; border-radius: 12px; background-color: #ffffff; box-shadow: 0 4px 6px rgba(0,0,0,0.05);">
<div style="background: linear-gradient(90deg, #1a1a2e 0%, #161625 100%); color: #ffffff; padding: 20px; border-radius: 8px; text-align: center;">
<h2 style="margin: 0; text-transform: uppercase; letter-spacing: 1px;">ИКП — Детали заказа</h2>
</div>
<p style="margin-top: 20px;">Здравствуйте!</p>
<p>На сайте зарегистрирована заявка с новым статусом:</p>
<table style="width: 100%; border-collapse: collapse; margin-top: 20px;">
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568; width: 40%;">ID заявки:</td>
<td style="padding: 10px; font-family: monospace;">{reg_id}</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Статус заказа:</td>
<td style="padding: 10px;">
<span style="display: inline-block; padding: 4px 10px; font-size: 0.8rem; font-weight: bold; border-radius: 4px; color: { '#ffffff' if paid else '#744210' }; background-color: { '#48bb78' if paid else '#ecc94b' };">
{status_str}
</span>
</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Выбранная услуга:</td>
<td style="padding: 10px; font-weight: bold;">{data.get('serviceName')}</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Сумма платежа:</td>
<td style="padding: 10px; font-weight: bold; color: #7c3aed;">{data.get('servicePrice')} ₽</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Имя клиента:</td>
<td style="padding: 10px;">{data.get('name')}</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Номер телефона:</td>
<td style="padding: 10px; font-family: monospace;">{data.get('phone')}</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">E-mail:</td>
<td style="padding: 10px;">{data.get('email')}</td>
</tr>
<tr style="border-bottom: 1px solid #edf2f7;">
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Профиль VK:</td>
<td style="padding: 10px;"><a href="{data.get('vk')}" style="color: #3182ce;">{data.get('vk')}</a></td>
</tr>
<tr>
<td style="padding: 10px; font-weight: bold; color: #4a5568;">Дата регистрации:</td>
<td style="padding: 10px; font-size: 0.9rem; color: #718096;">{datetime.now().strftime("%d.%m.%Y %H:%M:%S")}</td>
</tr>
</table>
<div style="margin-top: 30px; border-top: 1px solid #e2e8f0; padding-top: 20px; text-align: center; font-size: 0.75rem; color: #a0aec0;">
Это автоматическое системное уведомление от ИКП.
</div>
</div>
</body>
</html>
"""
server = None
try:
msg = MIMEMultipart('alternative')
msg['Subject'] = subject
msg['From'] = smtp_from
msg['To'] = ', '.join(smtp_to) # заголовок — строка
msg.attach(MIMEText(html_content, 'html', 'utf-8'))
if smtp_port == 465:
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
else:
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
server.login(smtp_user, smtp_password)
server.sendmail(smtp_from, smtp_to, msg.as_string()) # smtp_to — список
print(f"[SMTP] Письмо о заказе {reg_id} отправлено на {smtp_to}")
return True
except Exception as e:
print(f"[SMTP] Ошибка отправки письма: {e}")
return False
finally:
if server:
try:
server.quit()
except Exception:
pass
# ====================================================
# Flask Web App Routes
# ====================================================
# Template filters
@app.template_filter('numberformat')
def numberformat_filter(value):
try:
return f"{int(value):,}".replace(",", " ")
except (ValueError, TypeError):
return value
# Serve static files logic
@app.route('/')
def root_index():
return app.send_static_file('index.html')
@app.route('/form/')
@app.route('/form/index.html')
def form_index():
return app.send_static_file('form/index.html')
@app.after_request
def add_cors_headers(response):
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
return response
@app.route('/api/services', methods=['GET'])
def get_api_services():
try:
services = db.get_services()
return jsonify({"status": "success", "services": services})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/submit', methods=['POST', 'OPTIONS'])
def submit():
if request.method == 'OPTIONS':
return '', 204
try:
data = request.json
if not data:
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
# Validate mandatory data
name = data.get("name", "").strip()
phone = data.get("phone", "").strip()
email = data.get("email", "").strip()
if not name or not phone or not email:
return jsonify({"status": "error", "message": "Заполните обязательные поля: Имя, Телефон, E-mail"}), 400
init_excel()
# Load excel to generate sequential user ID
try:
wb = load_workbook(FILE_NAME)
ws = wb.active
except Exception:
init_excel()
wb = load_workbook(FILE_NAME)
ws = wb.active
reg_id = get_next_user_id(ws)
# Secure price lookup based on serviceId from SQLite database
service_id = str(data.get("serviceId", "web_1")).strip()
db_services = db.get_services()
service_info = db_services.get(service_id)
if not service_info:
return jsonify({"status": "error", "message": f"Выбранная услуга '{service_id}' не найдена в базе данных"}), 400
service_name = service_info["name"]
amount = service_info["price_numeric"]
# 1. YooKassa checkout generation
return_url = f"{request.host_url}?success=true&reg_id={reg_id}&service_id={service_id}"
payment_info = create_yookassa_payment(
reg_id=reg_id,
amount=amount,
description=f"Оплата: {service_name} (ИКП)",
return_url=return_url,
customer_email=email,
customer_phone=phone,
customer_name=name
)
payment_id = None
payment_url = None
if payment_info:
payment_id = payment_info.get("payment_id")
payment_url = payment_info.get("confirmation_url")
# Update payload dict with secure details
data_to_save = data.copy()
data_to_save["yookassaPaymentId"] = payment_id or ""
data_to_save["serviceName"] = service_name
data_to_save["servicePrice"] = amount
# 2. Save registration in SQLite database as pending
db.add_web_registration(reg_id, {
"name": name,
"phone": phone,
"email": email,
"vk": data.get("vk", "").strip(),
"agreeOferta": data.get("agreeOferta"),
"agreePolicy": data.get("agreePolicy"),
"agreeNewsletter": data.get("agreeNewsletter"),
"serviceName": service_name,
"servicePrice": amount,
"paymentStatus": "pending",
"yookassaPaymentId": payment_id
})
# 3. Save initial registration in Excel
add_registration_to_excel(reg_id, data_to_save)
# 4. Trigger operator alerts (unpaid yet)
send_vk_operator_notification(reg_id, data_to_save, paid=False)
send_email_notification(reg_id, data_to_save, paid=False)
res = {"status": "success", "message": "Заявка зарегистрирована!"}
if payment_url:
res["payment_url"] = payment_url
return jsonify(res)
except PermissionError:
return jsonify({
"status": "error",
"message": "Файл таблицы Excel заблокирован. Пожалуйста, закройте Excel на сервере и попробуйте отправить форму заново."
}), 500
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({"status": "error", "message": str(e)}), 500
# ====================================================
# YooKassa Webhook Listener
# ====================================================
@app.route('/webhook/yookassa', methods=['POST'])
def yookassa_webhook():
try:
# Enforce YooKassa IP filtering if enabled
if config.VERIFY_WEBHOOK_IP:
# Handle proxy and Docker headers
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
client_ip = x_forwarded_for.split(',')[0].strip()
else:
client_ip = request.remote_addr
if not is_yookassa_ip(client_ip):
print(f"[SECURITY WARNING] Заблокирован запрос к вебхуку от недоверенного IP: {client_ip}")
return jsonify({"status": "error", "message": "Access Denied: Untrusted IP"}), 403
event_data = request.json
if not event_data:
return 'Empty body', 400
event_type = event_data.get("event")
if event_type == "payment.succeeded":
payment_obj = event_data.get("object", {})
payment_id = payment_obj.get("id")
metadata = payment_obj.get("metadata", {})
reg_id = metadata.get("registration_id")
if reg_id:
# Retrieve existing record
reg = db.get_web_registration(reg_id)
if reg:
# Only process if status is not already paid
if reg.get("payment_status") != "paid":
paid_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 1. Update SQLite Status
db.update_web_payment_status(reg_id, "paid", payment_id, paid_at)
# 2. Update Excel file Status
update_excel_payment_status(reg_id, "Оплачено", paid_at)
# Rebuild data dict for notification helpers
notify_data = {
"name": reg.get("name"),
"phone": reg.get("phone"),
"email": reg.get("email"),
"vk": reg.get("vk_link"),
"agreeOferta": reg.get("agree_oferta"),
"agreePolicy": reg.get("agree_policy"),
"agreeNewsletter": reg.get("agree_newsletter"),
"serviceName": reg.get("service_name"),
"servicePrice": reg.get("amount")
}
# 3. Send paid success notifications
send_vk_operator_notification(reg_id, notify_data, paid=True)
send_email_notification(reg_id, notify_data, paid=True)
print(f"[WEBHOOK] Заявка {reg_id} успешно переведена в статус ОПЛАЧЕНО.")
else:
print(f"[WEBHOOK] Заявка {reg_id} не найдена в базе данных.")
else:
print("[WEBHOOK] Отсутствует registration_id в метаданных платежа.")
return 'OK', 200
except Exception as e:
print(f"[WEBHOOK] Исключение при обработке вебхука: {e}")
return 'Internal Server Error', 500
# ====================================================
# Admin Cabinet Views (ЛК Администратора)
# ====================================================
LOGIN_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИКП Панель | Авторизация</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Montserrat:wght@700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #0a0a0f; }
.font-display { font-family: 'Montserrat', sans-serif; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center relative overflow-hidden bg-[radial-gradient(circle_at_center,_rgba(124,58,237,0.1)_0%,_rgba(10,10,15,1)_100%)]">
<div class="absolute -top-40 -left-40 w-96 h-96 bg-purple-600/10 rounded-full blur-[100px] pointer-events-none"></div>
<div class="absolute -bottom-40 -right-40 w-96 h-96 bg-blue-600/10 rounded-full blur-[100px] pointer-events-none"></div>
<div class="w-full max-w-md p-8 bg-slate-900/60 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl relative z-10">
<div class="text-center mb-8">
<h2 class="font-display font-black text-3xl text-white tracking-wider uppercase mb-2">ИКП Админ</h2>
<p class="text-xs font-mono text-purple-400 uppercase tracking-widest">Панель управления оплатами</p>
</div>
{% if error %}
<div class="mb-6 p-4 bg-red-950/50 border border-red-500/30 text-red-200 text-sm rounded-lg flex items-center gap-2">
<span>⚠️</span> {{ error }}
</div>
{% endif %}
<form action="{{ url_for('admin_login') }}" method="POST" class="space-y-6">
<div>
<label for="password" class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-2">Пароль администратора</label>
<input type="password" id="password" name="password" required placeholder="••••••••"
class="w-full px-4 py-3 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition">
</div>
<button type="submit" class="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-display font-black text-sm uppercase tracking-wider rounded-xl shadow-lg shadow-purple-600/20 hover:shadow-purple-600/40 transform hover:-translate-y-0.5 transition duration-200">
Войти в кабинет
</button>
</form>
</div>
</body>
</html>
"""
DASHBOARD_TEMPLATE = """
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ИКП Админ | Реестр оплат</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@800;900&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #050508; color: #e2e8f0; }
.font-display { font-family: 'Montserrat', sans-serif; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: #0c0c14; }
::-webkit-scrollbar-thumb { background: #1f1f2e; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #2d2d44; }
</style>
</head>
<body class="min-h-screen bg-[radial-gradient(ellipse_at_top,_rgba(26,15,46,0.8)_0%,_rgba(5,5,8,1)_80%)]">
<header class="border-b border-white/5 bg-black/40 backdrop-blur-md sticky top-0 z-30">
<div class="max-w-[1400px] mx-auto px-6 h-20 flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="font-display font-black text-2xl tracking-wider text-white uppercase bg-gradient-to-r from-purple-500 to-blue-500 bg-clip-text text-transparent">ИКП</span>
<span class="h-4 w-px bg-white/20"></span>
<span class="text-xs font-mono text-slate-400 uppercase tracking-widest">Панель управления оплат</span>
</div>
<a href="{{ url_for('admin_logout') }}" class="px-4 py-2 bg-red-950/20 hover:bg-red-950/40 border border-red-900/30 hover:border-red-500/50 text-red-200 text-xs font-bold uppercase tracking-wider rounded-lg transition">
Выйти
</a>
</div>
</header>
<main class="max-w-[1400px] mx-auto px-6 py-10 space-y-8">
<!-- Tab navigation -->
<div class="border border-white/5 bg-slate-900/40 rounded-2xl p-1.5 flex gap-2 max-w-xs">
<button onclick="switchTab('orders')" id="tab-btn-orders" class="flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-purple-600 text-white transition">Заявки</button>
<button onclick="switchTab('services')" id="tab-btn-services" class="flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-transparent text-slate-400 hover:text-slate-200 transition">Услуги</button>
</div>
<!-- SECTION: ORDERS -->
<div id="section-orders" class="space-y-8">
<!-- Stats cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="p-6 bg-slate-900/40 border border-white/5 rounded-2xl backdrop-blur-sm">
<p class="text-xs font-mono text-slate-400 uppercase tracking-wider mb-1">Всего заявок</p>
<p class="font-display font-black text-3xl text-white">{{ stats.total }}</p>
</div>
<div class="p-6 bg-slate-900/40 border border-white/5 rounded-2xl backdrop-blur-sm">
<p class="text-xs font-mono text-emerald-400 uppercase tracking-wider mb-1">Оплачено заказов</p>
<p class="font-display font-black text-3xl text-emerald-400">{{ stats.paid_count }}</p>
</div>
<div class="p-6 bg-slate-900/40 border border-white/5 rounded-2xl backdrop-blur-sm">
<p class="text-xs font-mono text-yellow-500 uppercase tracking-wider mb-1">Ожидает оплаты</p>
<p class="font-display font-black text-3xl text-yellow-500">{{ stats.pending_count }}</p>
</div>
<div class="p-6 bg-slate-900/40 border border-white/5 rounded-2xl backdrop-blur-sm">
<p class="text-xs font-mono text-blue-400 uppercase tracking-wider mb-1">Сумма сборов</p>
<p class="font-display font-black text-3xl text-blue-400">{{ stats.total_revenue | int | numberformat }} ₽</p>
</div>
</div>
<!-- Filter and Search controls -->
<div class="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-4 p-4 bg-slate-900/20 border border-white/5 rounded-2xl">
<div class="flex flex-wrap items-center gap-2">
<button onclick="filterStatus('all')" class="status-tab px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider bg-purple-600 text-white transition" id="tab-all">Все</button>
<button onclick="filterStatus('paid')" class="status-tab px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700 transition" id="tab-paid">Оплачено</button>
<button onclick="filterStatus('pending')" class="status-tab px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-wider bg-slate-800 text-slate-300 hover:bg-slate-700 transition" id="tab-pending">Ожидает оплаты</button>
</div>
<div class="relative flex-1 max-w-md">
<input type="text" id="search" oninput="searchTable()" placeholder="Поиск по имени, телефону или email..."
class="w-full pl-10 pr-4 py-2 bg-black/40 border border-white/5 rounded-xl text-sm text-white placeholder-slate-500 focus:outline-none focus:border-purple-500 transition">
<span class="absolute left-3.5 top-2.5 text-slate-500">🔍</span>
</div>
</div>
<!-- Orders Table -->
<div class="bg-slate-900/20 border border-white/5 rounded-2xl overflow-hidden shadow-xl">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse" id="orders-table">
<thead>
<tr class="border-b border-white/5 bg-white/[0.02] text-xs font-mono text-slate-400 uppercase tracking-wider">
<th class="p-4">ID</th>
<th class="p-4">Клиент</th>
<th class="p-4">Контакты</th>
<th class="p-4">Услуга</th>
<th class="p-4">Сумма</th>
<th class="p-4 text-center">Статус</th>
<th class="p-4">Регистрация</th>
<th class="p-4 text-center">Действие</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.03]">
{% for reg in registrations %}
<tr class="order-row hover:bg-white/[0.01] transition-all text-sm" data-status="{{ reg.payment_status }}">
<td class="p-4 font-mono text-xs text-slate-400">{{ reg.id }}</td>
<td class="p-4 font-semibold text-white">
{{ reg.name }}
</td>
<td class="p-4 space-y-1">
<div class="flex items-center gap-2 text-xs">
<span>📞</span> <span class="search-target font-mono">{{ reg.phone }}</span>
</div>
<div class="flex items-center gap-2 text-xs">
<span>✉️</span> <span class="search-target font-mono text-slate-400">{{ reg.email }}</span>
</div>
{% if reg.vk_link %}
<div class="flex items-center gap-2 text-xs">
<span>🌐</span> <a href="{{ reg.vk_link }}" target="_blank" class="text-blue-400 hover:underline font-mono text-[11px] truncate max-w-[150px]">{{ reg.vk_link }}</a>
</div>
{% endif %}
</td>
<td class="p-4">
<span class="px-2.5 py-1 bg-white/5 rounded text-xs text-glow-blue border border-white/5 font-semibold text-white">{{ reg.service_name }}</span>
</td>
<td class="p-4 font-mono font-bold text-white">{{ reg.amount | int }} ₽</td>
<td class="p-4 text-center">
{% if reg.payment_status == 'paid' %}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold uppercase tracking-wider rounded-full">
<span class="w-1.5 h-1.5 bg-emerald-400 rounded-full"></span> Оплачено
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-yellow-500/10 border border-yellow-500/20 text-yellow-500 text-xs font-bold uppercase tracking-wider rounded-full">
<span class="w-1.5 h-1.5 bg-yellow-500 rounded-full animate-pulse"></span> Ожидает
</span>
{% endif %}
</td>
<td class="p-4 text-xs text-slate-400 space-y-0.5">
<div>Рег: {{ reg.created_at }}</div>
{% if reg.paid_at %}
<div class="text-emerald-400 font-mono text-[10px]">Опл: {{ reg.paid_at }}</div>
{% endif %}
</td>
<td class="p-4 text-center">
{% if reg.payment_status != 'paid' and reg.yookassa_payment_id %}
<button onclick="checkStatus('{{ reg.id }}', this)"
class="px-3 py-1.5 bg-purple-950/30 hover:bg-purple-900/50 border border-purple-900/40 hover:border-purple-500/50 text-purple-300 text-xs font-bold uppercase tracking-wider rounded-lg transition flex items-center gap-1 mx-auto">
<span>🔄</span> Проверить
</button>
{% else %}
<span class="text-xs text-slate-500 font-mono">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- SECTION: SERVICES -->
<div id="section-services" class="hidden space-y-8">
<!-- Services Header and Add Button -->
<div class="flex justify-between items-center bg-slate-900/40 border border-white/5 rounded-2xl p-6">
<div>
<h2 class="font-display font-black text-2xl text-white uppercase tracking-wider">Каталог услуг</h2>
<p class="text-xs font-mono text-purple-400 uppercase tracking-widest">Управление тарифами и ценами</p>
</div>
<button onclick="openAddModal()" class="px-5 py-3 bg-purple-600 hover:bg-purple-500 text-white font-display font-black text-xs uppercase tracking-wider rounded-xl shadow-lg shadow-purple-600/20 hover:shadow-purple-600/40 transform hover:-translate-y-0.5 transition duration-200 flex items-center gap-2">
<span></span> Добавить услугу
</button>
</div>
<!-- Services Table -->
<div class="bg-slate-900/20 border border-white/5 rounded-2xl overflow-hidden shadow-xl">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse" id="services-table">
<thead>
<tr class="border-b border-white/5 bg-white/[0.02] text-xs font-mono text-slate-400 uppercase tracking-wider">
<th class="p-4">ID услуги</th>
<th class="p-4">Название</th>
<th class="p-4">Отображаемая цена</th>
<th class="p-4">Числовая цена (для YooKassa)</th>
<th class="p-4">Описание / Игры</th>
<th class="p-4 text-center">Статус</th>
<th class="p-4 text-center">Действия</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.03]">
{% for svc in services %}
<tr class="hover:bg-white/[0.01] transition-all text-sm">
<td class="p-4 font-mono text-xs text-purple-400 font-semibold">{{ svc.id }}</td>
<td class="p-4 font-semibold text-white">{{ svc.name }}</td>
<td class="p-4 font-mono text-slate-300">{{ svc.price }}</td>
<td class="p-4 font-mono font-bold text-white">{{ svc.price_numeric | int }} ₽</td>
<td class="p-4 text-slate-400 max-w-xs truncate">{{ svc.description }}</td>
<td class="p-4 text-center">
{% if svc.is_active == 1 %}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold uppercase tracking-wider rounded-full">
<span class="w-1.5 h-1.5 bg-emerald-400 rounded-full"></span> Активна
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 bg-slate-500/10 border border-slate-500/20 text-slate-400 text-xs font-bold uppercase tracking-wider rounded-full">
<span class="w-1.5 h-1.5 bg-slate-400 rounded-full"></span> Отключена
</span>
{% endif %}
</td>
<td class="p-4 text-center">
<button onclick="openEditModal('{{ svc.id }}', '{{ svc.name | replace("'", "\\'") }}', '{{ svc.price | replace("'", "\\'") }}', '{{ svc.price_numeric }}', '{{ svc.description | replace("'", "\\'") | replace("\n", " ") }}', {{ svc.is_active }})"
class="inline-flex items-center gap-1 px-3 py-1.5 bg-blue-950/30 hover:bg-blue-900/50 border border-blue-900/40 hover:border-blue-500/50 text-blue-300 text-xs font-bold uppercase tracking-wider rounded-lg transition">
✏️ Ред.
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</main>
<!-- Modal Add Service -->
<div id="modal-add-service" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center hidden">
<div class="w-full max-w-lg p-8 bg-slate-900 border border-white/10 rounded-2xl shadow-2xl relative">
<button onclick="closeAddModal()" class="absolute top-4 right-4 text-slate-400 hover:text-white text-xl">✕</button>
<h3 class="font-display font-black text-2xl text-white uppercase tracking-wider mb-6">Добавить новую услугу</h3>
<form id="form-add-service" class="space-y-4" onsubmit="submitAddService(event)">
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">ID услуги (уникальный, латиница)</label>
<input type="text" id="add-svc-id" required placeholder="например: web_6"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Название услуги</label>
<input type="text" id="add-svc-name" required placeholder="например: Индивидуальное занятие"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Отображаемая цена</label>
<input type="text" id="add-svc-price" required placeholder="например: 1 500 ₽"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Числовая цена (для YooKassa)</label>
<input type="number" id="add-svc-price-num" required step="0.01" placeholder="1500"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Описание / Игры</label>
<textarea id="add-svc-desc" rows="3" placeholder="Описание услуги..."
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition"></textarea>
</div>
<button type="submit" class="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-display font-black text-sm uppercase tracking-wider rounded-xl shadow-lg transition">
Добавить услугу
</button>
</form>
</div>
</div>
<!-- Modal Edit Service -->
<div id="modal-edit-service" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center hidden">
<div class="w-full max-w-lg p-8 bg-slate-900 border border-white/10 rounded-2xl shadow-2xl relative">
<button onclick="closeEditModal()" class="absolute top-4 right-4 text-slate-400 hover:text-white text-xl">✕</button>
<h3 class="font-display font-black text-2xl text-white uppercase tracking-wider mb-6">Редактировать услугу</h3>
<form id="form-edit-service" class="space-y-4" onsubmit="submitEditService(event)">
<input type="hidden" id="edit-svc-id">
<div>
<label class="block text-xs font-bold text-slate-400 uppercase tracking-wider mb-1">ID услуги (нельзя изменить)</label>
<input type="text" id="edit-svc-id-display" readonly
class="w-full px-4 py-2 bg-black/30 border border-white/5 rounded-xl text-slate-500 focus:outline-none cursor-not-allowed">
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Название услуги</label>
<input type="text" id="edit-svc-name" required
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Отображаемая цена</label>
<input type="text" id="edit-svc-price" required
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Числовая цена (для YooKassa)</label>
<input type="number" id="edit-svc-price-num" required step="0.01"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition">
</div>
</div>
<div>
<label class="block text-xs font-bold text-blue-400 uppercase tracking-wider mb-1">Описание / Игры</label>
<textarea id="edit-svc-desc" rows="3"
class="w-full px-4 py-2 bg-black/50 border border-white/10 rounded-xl text-white placeholder-slate-600 focus:outline-none focus:border-purple-500 transition"></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="edit-svc-active" class="w-4 h-4 rounded text-purple-600 focus:ring-purple-500 bg-black/50 border-white/10">
<label for="edit-svc-active" class="text-sm font-bold text-slate-300 uppercase tracking-wider cursor-pointer">Активна (доступна для выбора и оплаты)</label>
</div>
<button type="submit" class="w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-display font-black text-sm uppercase tracking-wider rounded-xl shadow-lg transition">
Сохранить изменения
</button>
</form>
</div>
</div>
<script id="services-data" type="application/json">
{{ services | tojson }}
</script>
<script>
let currentFilter = 'all';
const servicesData = JSON.parse(document.getElementById('services-data').textContent);
function switchTab(tab) {
if (tab === 'orders') {
document.getElementById('section-orders').classList.remove('hidden');
document.getElementById('section-services').classList.add('hidden');
document.getElementById('tab-btn-orders').className = "flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-purple-600 text-white transition";
document.getElementById('tab-btn-services').className = "flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-transparent text-slate-400 hover:text-slate-200 transition";
} else {
document.getElementById('section-orders').classList.add('hidden');
document.getElementById('section-services').classList.remove('hidden');
document.getElementById('tab-btn-services').className = "flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-purple-600 text-white transition";
document.getElementById('tab-btn-orders').className = "flex-1 py-2 rounded-xl text-xs font-bold uppercase tracking-wider bg-transparent text-slate-400 hover:text-slate-200 transition";
}
}
// Modals management
function openAddModal() {
document.getElementById('modal-add-service').classList.remove('hidden');
}
function closeAddModal() {
document.getElementById('modal-add-service').classList.add('hidden');
document.getElementById('form-add-service').reset();
}
function openEditModal(id) {
const svc = servicesData.find(s => s.id === id);
if (!svc) return;
document.getElementById('edit-svc-id').value = svc.id;
document.getElementById('edit-svc-id-display').value = svc.id;
document.getElementById('edit-svc-name').value = svc.name;
document.getElementById('edit-svc-price').value = svc.price;
document.getElementById('edit-svc-price-num').value = svc.price_numeric;
document.getElementById('edit-svc-desc').value = svc.description;
document.getElementById('edit-svc-active').checked = (svc.is_active === 1);
document.getElementById('modal-edit-service').classList.remove('hidden');
}
function closeEditModal() {
document.getElementById('modal-edit-service').classList.add('hidden');
document.getElementById('form-edit-service').reset();
}
function submitAddService(e) {
e.preventDefault();
const id = document.getElementById('add-svc-id').value.trim();
const name = document.getElementById('add-svc-name').value.trim();
const price = document.getElementById('add-svc-price').value.trim();
const price_numeric = parseFloat(document.getElementById('add-svc-price-num').value);
const description = document.getElementById('add-svc-desc').value.trim();
fetch('/admin/services/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, name, price, price_numeric, description })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('Услуга успешно добавлена!');
window.location.reload();
} else {
alert('Ошибка: ' + data.message);
}
})
.catch(err => {
console.error(err);
alert('Не удалось связаться с сервером');
});
}
function submitEditService(e) {
e.preventDefault();
const id = document.getElementById('edit-svc-id').value;
const name = document.getElementById('edit-svc-name').value.trim();
const price = document.getElementById('edit-svc-price').value.trim();
const price_numeric = parseFloat(document.getElementById('edit-svc-price-num').value);
const description = document.getElementById('edit-svc-desc').value.trim();
const is_active = document.getElementById('edit-svc-active').checked ? 1 : 0;
fetch(`/admin/services/edit/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, price, price_numeric, description, is_active })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('Услуга успешно обновлена!');
window.location.reload();
} else {
alert('Ошибка: ' + data.message);
}
})
.catch(err => {
console.error(err);
alert('Не удалось связаться с сервером');
});
}
function filterStatus(status) {
currentFilter = status;
// Toggle active styles on tabs
document.querySelectorAll('.status-tab').forEach(tab => {
tab.classList.remove('bg-purple-600', 'text-white');
tab.classList.add('bg-slate-800', 'text-slate-300');
});
const activeTab = document.getElementById('tab-' + status);
activeTab.classList.remove('bg-slate-800', 'text-slate-300');
activeTab.classList.add('bg-purple-600', 'text-white');
applyFilterAndSearch();
}
function searchTable() {
applyFilterAndSearch();
}
function applyFilterAndSearch() {
const query = document.getElementById('search').value.toLowerCase();
const rows = document.querySelectorAll('.order-row');
rows.forEach(row => {
const status = row.getAttribute('data-status');
// Get name and other fields text content
const name = row.querySelector('.font-semibold').innerText.toLowerCase();
const searchTargets = row.querySelectorAll('.search-target');
let targetText = name;
searchTargets.forEach(t => targetText += ' ' + t.innerText.toLowerCase());
const matchesFilter = (currentFilter === 'all') || (status === currentFilter);
const matchesSearch = targetText.includes(query);
if (matchesFilter && matchesSearch) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
function checkStatus(regId, button) {
const originalText = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span>🔄</span> Проверка...';
fetch(`/admin/check-payment/${regId}`, {
method: 'POST'
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert(`Новый статус: ${data.payment_status == 'paid' ? 'ОПЛАЧЕНО!' : 'Ожидает оплаты'}`);
if (data.payment_status === 'paid') {
window.location.reload();
}
} else {
alert('Ошибка: ' + data.message);
}
})
.catch(err => {
console.error(err);
alert('Сбой соединения с сервером');
})
.finally(() => {
button.disabled = false;
button.innerHTML = originalText;
});
}
</script>
</body>
</html>
"""
@app.route('/admin', methods=['GET'])
def admin_dashboard():
if not session.get('logged_in'):
return render_template_string(LOGIN_TEMPLATE)
# Fetch registrations and services
registrations = db.get_all_web_registrations()
services = db.get_all_services_list()
# Calculate stats
stats = {
"total": len(registrations),
"paid_count": sum(1 for r in registrations if r["payment_status"] == "paid"),
"pending_count": sum(1 for r in registrations if r["payment_status"] != "paid"),
"total_revenue": sum(r["amount"] for r in registrations if r["payment_status"] == "paid")
}
return render_template_string(DASHBOARD_TEMPLATE, registrations=registrations, stats=stats, services=services)
@app.route('/admin/services/add', methods=['POST'])
def admin_add_service():
if not session.get('logged_in'):
return jsonify({"status": "error", "message": "Не авторизован"}), 403
try:
data = request.json
if not data:
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
service_id = data.get("id", "").strip()
name = data.get("name", "").strip()
price = data.get("price", "").strip()
price_numeric = data.get("price_numeric")
description = data.get("description", "").strip()
if not service_id or not name or not price or price_numeric is None:
return jsonify({"status": "error", "message": "Заполните все обязательные поля"}), 400
try:
price_numeric = float(price_numeric)
except ValueError:
return jsonify({"status": "error", "message": "Числовая цена должна быть числом"}), 400
success = db.add_service(service_id, name, price, price_numeric, description)
if success:
return jsonify({"status": "success", "message": "Услуга успешно добавлена"})
else:
return jsonify({"status": "error", "message": "Услуга с таким ID уже существует"}), 400
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/admin/services/edit/<service_id>', methods=['POST'])
def admin_edit_service(service_id):
if not session.get('logged_in'):
return jsonify({"status": "error", "message": "Не авторизован"}), 403
try:
data = request.json
if not data:
return jsonify({"status": "error", "message": "Отсутствуют данные запроса"}), 400
name = data.get("name", "").strip()
price = data.get("price", "").strip()
price_numeric = data.get("price_numeric")
description = data.get("description", "").strip()
is_active = data.get("is_active", 1)
if not name or not price or price_numeric is None:
return jsonify({"status": "error", "message": "Заполните все обязательные поля"}), 400
try:
price_numeric = float(price_numeric)
except ValueError:
return jsonify({"status": "error", "message": "Числовая цена должна быть числом"}), 400
success = db.update_service(service_id, name, price, price_numeric, description, int(is_active))
if success:
return jsonify({"status": "success", "message": "Услуга успешно сохранена"})
else:
return jsonify({"status": "error", "message": "Не удалось обновить услугу"}), 400
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/admin/login', methods=['POST'])
def admin_login():
password = request.form.get("password")
if password == config.ADMIN_PASSWORD:
session['logged_in'] = True
return redirect(url_for('admin_dashboard'))
else:
return render_template_string(LOGIN_TEMPLATE, error="Неверный пароль администратора")
@app.route('/admin/logout', methods=['GET'])
def admin_logout():
session.pop('logged_in', None)
return redirect(url_for('admin_dashboard'))
@app.route('/admin/check-payment/<reg_id>', methods=['POST'])
def admin_check_payment(reg_id):
if not session.get('logged_in'):
return jsonify({"status": "error", "message": "Не авторизован"}), 403
try:
reg = db.get_web_registration(reg_id)
if not reg:
return jsonify({"status": "error", "message": "Заявка не найдена"}), 404
payment_id = reg.get("yookassa_payment_id")
if not payment_id:
return jsonify({"status": "error", "message": "У этой заявки нет ID платежа ЮKassa"}), 400
yoo_status = check_yookassa_payment_status(payment_id)
if not yoo_status:
return jsonify({"status": "error", "message": "Не удалось связаться с ЮKassa API"}), 500
# Update if paid
if yoo_status == "succeeded" and reg.get("payment_status") != "paid":
paid_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db.update_web_payment_status(reg_id, "paid", payment_id, paid_at)
update_excel_payment_status(reg_id, "Оплачено", paid_at)
# Send paid notifications
notify_data = {
"name": reg.get("name"),
"phone": reg.get("phone"),
"email": reg.get("email"),
"vk": reg.get("vk_link"),
"agreeOferta": reg.get("agree_oferta"),
"agreePolicy": reg.get("agree_policy"),
"agreeNewsletter": reg.get("agree_newsletter"),
"serviceName": reg.get("service_name"),
"servicePrice": reg.get("amount")
}
send_vk_operator_notification(reg_id, notify_data, paid=True)
send_email_notification(reg_id, notify_data, paid=True)
return jsonify({"status": "success", "payment_status": "paid"})
return jsonify({"status": "success", "payment_status": reg.get("payment_status")})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
def run_server(port=8000):
init_excel()
print(f"\n=======================================================")
print(f"Flask-сервер ИКП запущен на http://localhost:{port}/")
print(f"Главная страница: http://localhost:{port}/")
print(f"Форма регистрации: http://localhost:{port}/form/index.html")
print(f"Панель администратора: http://localhost:{port}/admin")
print(f"Данные пишутся в SQLite и Excel: {FILE_NAME}")
print(f"Нажмите Ctrl+C для остановки сервера.")
print(f"=======================================================\n")
app.run(host='0.0.0.0', port=port, debug=True)
if __name__ == '__main__':
run_server()