1482 lines
72 KiB
Python
1482 lines
72 KiB
Python
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®_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 (ЛК Администратора)
|
||
# ====================================================
|
||
|
||
# Шаблон страницы ошибки доступа
|
||
ACCESS_DENIED_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; }</style>
|
||
</head>
|
||
<body class="min-h-screen flex items-center justify-center bg-[radial-gradient(circle_at_center,_rgba(124,58,237,0.1)_0%,_rgba(10,10,15,1)_100%)]">
|
||
<div class="w-full max-w-md p-8 bg-slate-900/60 backdrop-blur-xl border border-white/10 rounded-2xl shadow-2xl text-center">
|
||
<div class="text-5xl mb-4">🔒</div>
|
||
<h2 class="font-display font-black text-2xl text-white uppercase mb-2" style="font-family:'Montserrat',sans-serif">Ошибка доступа</h2>
|
||
<p class="text-slate-400 text-sm mb-6">{{ error }}</p>
|
||
<a href="{{ login_url }}" class="inline-block w-full py-3 bg-purple-600 hover:bg-purple-500 text-white font-bold text-sm uppercase tracking-wider rounded-xl transition">
|
||
Войти через Authentik
|
||
</a>
|
||
</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 space-x-2">
|
||
<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>
|
||
<button onclick="deleteService('{{ svc.id }}', '{{ svc.name | replace("'", "\\'") }}')"
|
||
class="inline-flex items-center gap-1 px-3 py-1.5 bg-red-950/30 hover:bg-red-900/50 border border-red-900/40 hover:border-red-500/50 text-red-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 deleteService(id, name) {
|
||
if (!confirm(`Вы действительно хотите безвозвратно удалить услугу "${name}" (ID: ${id})?`)) {
|
||
return;
|
||
}
|
||
|
||
fetch(`/admin/services/delete/${id}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
})
|
||
.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'):
|
||
# Генерируем state для защиты от CSRF
|
||
state = str(uuid.uuid4())
|
||
session['oauth_state'] = state
|
||
|
||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||
params = {
|
||
'client_id': config.AUTHENTIK_CLIENT_ID,
|
||
'redirect_uri': config.AUTHENTIK_REDIRECT_URI,
|
||
'response_type': 'code',
|
||
'scope': 'openid email profile',
|
||
'state': state,
|
||
}
|
||
auth_url = f"{base}/application/o/authorize/?" + "&".join(f"{k}={v}" for k, v in params.items())
|
||
return redirect(auth_url)
|
||
|
||
# Загрузить данные
|
||
registrations = db.get_all_web_registrations()
|
||
services = db.get_all_services_list()
|
||
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/callback', methods=['GET'])
|
||
def admin_callback():
|
||
"""OIDC callback от Authentik."""
|
||
error = request.args.get('error')
|
||
if error:
|
||
print(f"[OIDC] Ошибка авторизации: {error} — {request.args.get('error_description', '')}")
|
||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||
login_url = f"{base}/application/o/authorize/?client_id={config.AUTHENTIK_CLIENT_ID}&redirect_uri={config.AUTHENTIK_REDIRECT_URI}&response_type=code&scope=openid+email+profile"
|
||
return render_template_string(ACCESS_DENIED_TEMPLATE,
|
||
error=f"Ошибка от Authentik: {error}",
|
||
login_url=login_url), 403
|
||
|
||
# Проверка state против CSRF
|
||
state = request.args.get('state')
|
||
if not state or state != session.get('oauth_state'):
|
||
print("[OIDC] Неверный state — возможная CSRF-атака")
|
||
return "403 Invalid state", 403
|
||
session.pop('oauth_state', None)
|
||
|
||
code = request.args.get('code')
|
||
if not code:
|
||
return "403 No code returned", 403
|
||
|
||
# Обмен кода на токен
|
||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||
token_url = f"{base}/application/o/token/"
|
||
try:
|
||
token_resp = requests.post(token_url, data={
|
||
'grant_type': 'authorization_code',
|
||
'code': code,
|
||
'redirect_uri': config.AUTHENTIK_REDIRECT_URI,
|
||
'client_id': config.AUTHENTIK_CLIENT_ID,
|
||
'client_secret': config.AUTHENTIK_CLIENT_SECRET,
|
||
}, timeout=15)
|
||
token_data = token_resp.json()
|
||
except Exception as e:
|
||
print(f"[OIDC] Ошибка получения токена: {e}")
|
||
return "500 Token exchange failed", 500
|
||
|
||
if 'error' in token_data:
|
||
print(f"[OIDC] Ошибка токена: {token_data}")
|
||
return f"403 {token_data.get('error_description', token_data['error'])}", 403
|
||
|
||
access_token = token_data.get('access_token')
|
||
|
||
# Получаем данные пользователя
|
||
try:
|
||
userinfo_resp = requests.get(f"{base}/application/o/userinfo/",
|
||
headers={'Authorization': f'Bearer {access_token}'},
|
||
timeout=10)
|
||
userinfo = userinfo_resp.json()
|
||
except Exception as e:
|
||
print(f"[OIDC] Ошибка получения userinfo: {e}")
|
||
return "500 Userinfo request failed", 500
|
||
|
||
print(f"[OIDC] Успешный вход: {userinfo.get('email') or userinfo.get('preferred_username', '?')}")
|
||
session['logged_in'] = True
|
||
session['user_email'] = userinfo.get('email', '')
|
||
session['user_name'] = userinfo.get('name') or userinfo.get('preferred_username', '')
|
||
return redirect(url_for('admin_dashboard'))
|
||
|
||
|
||
@app.route('/admin/login', methods=['POST', 'GET'])
|
||
def admin_login():
|
||
"""Legacy-маршрут — перенаправляет на OIDC."""
|
||
return redirect(url_for('admin_dashboard'))
|
||
|
||
|
||
@app.route('/admin/logout', methods=['GET'])
|
||
def admin_logout():
|
||
session.clear()
|
||
# Выход также из Authentik
|
||
base = config.AUTHENTIK_BASE_URL.rstrip('/')
|
||
if base:
|
||
return redirect(f"{base}/application/o/ikp-admin/end-session/")
|
||
return redirect(url_for('admin_dashboard'))
|
||
|
||
@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.get_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:
|
||
return jsonify({"status": "error", "message": "ID, название и цена обязательны для заполнения"}), 400
|
||
|
||
try:
|
||
price_numeric = float(price_numeric)
|
||
except (ValueError, TypeError):
|
||
price_numeric = 0.0
|
||
|
||
success = db.add_service(service_id, name, price, price_numeric, description)
|
||
if success:
|
||
return jsonify({"status": "success", "message": "Услуга добавлена"})
|
||
else:
|
||
return jsonify({"status": "error", "message": f"Услуга с ID '{service_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.get_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 = int(data.get("is_active", 1))
|
||
|
||
if not name or not price:
|
||
return jsonify({"status": "error", "message": "Название и цена обязательны для заполнения"}), 400
|
||
|
||
try:
|
||
price_numeric = float(price_numeric)
|
||
except (ValueError, TypeError):
|
||
price_numeric = 0.0
|
||
|
||
success = db.update_service(service_id, name, price, price_numeric, description, is_active)
|
||
if success:
|
||
return jsonify({"status": "success", "message": "Услуга обновлена"})
|
||
else:
|
||
return jsonify({"status": "error", "message": "Не удалось обновить услугу (возможно, ID не найден)"}), 404
|
||
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@app.route('/admin/services/delete/<service_id>', methods=['POST'])
|
||
def admin_delete_service(service_id):
|
||
if not session.get('logged_in'):
|
||
return jsonify({"status": "error", "message": "Не авторизован"}), 403
|
||
|
||
try:
|
||
success = db.delete_service(service_id)
|
||
if success:
|
||
return jsonify({"status": "success", "message": "Услуга успешно удалена"})
|
||
else:
|
||
return jsonify({"status": "error", "message": "Не удалось удалить услугу (возможно, она не найдена)"}), 404
|
||
except Exception as e:
|
||
return jsonify({"status": "error", "message": str(e)}), 500
|
||
|
||
@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()
|