diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dcdeb9d --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# Настройки ВКонтакте (для отправки уведомлений оператору) +# Токен сообщества ВКонтакте (с правами на сообщения) +VK_TOKEN=your_vk_community_token_here +# Идентификатор (ID) сообщества (только цифры) +VK_GROUP_ID=0 +# ID оператора ВКонтакте для отправки лидов (только цифры) +OPERATOR_PEER_ID=12345678 +# Список ID администраторов через запятую +ADMIN_IDS=12345678 + +# Настройки ЮKassa (YooKassa) +# Идентификатор магазина (Shop ID) из личного кабинета +YOOKASSA_SHOP_ID= +# Секретный ключ (API Key) из личного кабинета +YOOKASSA_SECRET_KEY= + +# Настройки почты по SMTP (для отправки писем о заказах) +# Адрес SMTP-сервера (например: smtp.yandex.ru, smtp.gmail.com, smtp.mail.ru) +SMTP_SERVER=smtp.yandex.ru +# Порт SMTP (465 для SSL/TLS или 587 для STARTTLS) +SMTP_PORT=465 +# Имя пользователя (ваш email полностью) +SMTP_USER= +# Пароль приложения (специальный пароль для сторонних приложений) +SMTP_PASSWORD= +# Email отправителя (обычно совпадает с SMTP_USER) +SMTP_FROM= +# Email получателя уведомлений (куда слать сообщения о заказах) +SMTP_TO= + +# Пароль для входа в Личный Кабинет Администратора (на сайте /admin) +ADMIN_PASSWORD=admin123 + +# Настройки агентской схемы для 54-ФЗ (чеки ОФД) +# Система налогообложения агента (1 - ОСНО, 2 - УСН доход, 3 - УСН доход-расход, 6 - Патент) +TAX_SYSTEM_CODE=2 +# Данные реального продавца (поставщика услуг) +SUPPLIER_NAME=ИП Груздев Артём Сергеевич +SUPPLIER_INN=370306440114 +SUPPLIER_PHONE=+79611157051 + +# Безопасность +# Включение верификации IP отправителя для вебхуков ЮKassa (True/False) +VERIFY_WEBHOOK_IP=True + diff --git a/.gitignore b/.gitignore index 5dba771..0354433 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ .idea/ Thumbs.db Desktop.ini +.env +.DS_Store +__pycache__/ +*.db +*.xlsx \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c85b83 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +# Установка рабочей директории +WORKDIR /app + +# Отключение кэширования pip и буферизации вывода Python +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Копирование требований и установка +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование остального исходного кода +COPY . . + +# Открытие порта +EXPOSE 8000 + +# Запуск сервера +CMD ["python", "server.py"] diff --git a/config.py b/config.py new file mode 100644 index 0000000..1d442bd --- /dev/null +++ b/config.py @@ -0,0 +1,62 @@ +import os + +# Load environment variables from .env in the project root +def _load_env(): + root_dir = os.path.dirname(os.path.abspath(__file__)) + dotenv_path = os.path.join(root_dir, ".env") + if os.path.exists(dotenv_path): + try: + with open(dotenv_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, val = line.split("=", 1) + val = val.strip().strip('"').strip("'") + os.environ[key.strip()] = val + except Exception as e: + print(f"Warning: Failed to load .env file: {e}") + +_load_env() + +# VK API Configuration +VK_TOKEN = os.getenv("VK_TOKEN", "your_vk_community_token_here") +GROUP_ID = int(os.getenv("VK_GROUP_ID", "0")) + +# Operator / Admin configuration +OPERATOR_PEER_ID = int(os.getenv("OPERATOR_PEER_ID", "0")) + +admin_ids_raw = os.getenv("ADMIN_IDS") +if admin_ids_raw: + try: + ADMIN_IDS = [int(x.strip()) for x in admin_ids_raw.split(",") if x.strip().isdigit()] + except Exception: + ADMIN_IDS = [OPERATOR_PEER_ID] if OPERATOR_PEER_ID else [] +else: + ADMIN_IDS = [OPERATOR_PEER_ID] if OPERATOR_PEER_ID else [] + +# YooKassa Configuration +YOOKASSA_SHOP_ID = os.getenv("YOOKASSA_SHOP_ID", "") +YOOKASSA_SECRET_KEY = os.getenv("YOOKASSA_SECRET_KEY", "") + +# SMTP configuration for email notifications +SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.yandex.ru") +SMTP_PORT = int(os.getenv("SMTP_PORT", "465")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", "") +SMTP_TO = os.getenv("SMTP_TO", "") + +# Admin Dashboard Password +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") + +# Supplier / Agent Configuration for 54-FZ receipts (Agent Scheme) +SUPPLIER_NAME = os.getenv("SUPPLIER_NAME", "ИП Груздев Артём Сергеевич") +SUPPLIER_INN = os.getenv("SUPPLIER_INN", "370306440114") +SUPPLIER_PHONE = os.getenv("SUPPLIER_PHONE", "+79611157051") +TAX_SYSTEM_CODE = int(os.getenv("TAX_SYSTEM_CODE", "2")) # Default: 2 (УСН Доход ООО "АЙТИГРУПП") + +# Security Configurations +VERIFY_WEBHOOK_IP = os.getenv("VERIFY_WEBHOOK_IP", "True").lower() == "true" + diff --git a/database.py b/database.py new file mode 100644 index 0000000..e2edd50 --- /dev/null +++ b/database.py @@ -0,0 +1,265 @@ +import sqlite3 +import os +from typing import Optional, Dict, Any + +class DatabaseManager: + def __init__(self, db_path: str = "bot_database.db"): + self.db_path = db_path + self._init_db() + + def _get_connection(self): + return sqlite3.connect(self.db_path) + + def _init_db(self): + with self._get_connection() as conn: + cursor = conn.cursor() + # Table for user states and collected form data (needed for VK bot sync) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS user_states ( + user_id INTEGER PRIMARY KEY, + state TEXT NOT NULL DEFAULT 'FREE_CHAT', + name TEXT, + phone TEXT, + question TEXT, + consent_1 TEXT, + consent_2 TEXT, + consent_3 TEXT, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Migration checks for existing databases (VK bot) + for col in ["consent_1", "consent_2", "consent_3"]: + try: + cursor.execute(f"ALTER TABLE user_states ADD COLUMN {col} TEXT") + except sqlite3.OperationalError: + pass + + # Table for logs / dialogue history if needed + cursor.execute(""" + CREATE TABLE IF NOT EXISTS dialogue_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + sender TEXT, -- 'user' or 'bot' + message TEXT, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Table for dynamic services + cursor.execute(""" + CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + price TEXT NOT NULL, + description TEXT NOT NULL, + is_active INTEGER DEFAULT 1 + ) + """) + + # Migration check: add price_numeric to services table + try: + cursor.execute("ALTER TABLE services ADD COLUMN price_numeric REAL DEFAULT 0.0") + except sqlite3.OperationalError: + pass + + # Seed default bot services if empty (legacy) + cursor.execute("SELECT COUNT(*) FROM services") + if cursor.fetchone()[0] == 0: + default_services = { + "1": { + "name": "Тренировка по Dota 2 🎮", + "price": "1 500 ₽/час", + "description": "Индивидуальное занятие с опытным тренером, разбор реплеев, позиционка и микроконтроль." + }, + "2": { + "name": "Тренировка по CS2 🔫", + "price": "1 500 ₽/час", + "description": "Анализ ошибок, тренировка стрельбы (aim), раскидки гранат и командное взаимодействие." + }, + "3": { + "name": "Пакет услуг 'Стандарт' 📦", + "price": "7 500 ₽", + "description": "5 индивидуальных тренировок по выбранной дисциплине + базовый план развития." + }, + "4": { + "name": "Пакет услуг 'Pro' 🌟", + "price": "14 000 ₽", + "description": "10 индивидуальных тренировок + детальный разбор матчей + постоянная поддержка тренера." + } + } + for key, svc in default_services.items(): + cursor.execute( + "INSERT INTO services (id, name, price, price_numeric, description) VALUES (?, ?, ?, ?, ?)", + (key, svc["name"], svc["price"], float(svc["price"].replace(" ", "").replace("₽/час", "").replace("₽", "")), svc["description"]) + ) + + # Seed default web services if they don't exist + web_services = { + "web_1": {"name": "Разовая тренировка", "price": "870 ₽", "price_numeric": 870.0, "description": "counter-strike, dota, tanks"}, + "web_2": {"name": "Абонемент на 4 занятия (Групповой)", "price": "2 790 ₽", "price_numeric": 2790.0, "description": "counter-strike, dota, tanks"}, + "web_3": {"name": "Абонемент на 8 занятий (Групповой)", "price": "4 990 ₽", "price_numeric": 4990.0, "description": "counter-strike, dota, tanks"}, + "web_4": {"name": "Абонемент на 4 занятия (Индивидуальный)", "price": "3 290 ₽", "price_numeric": 3290.0, "description": "counter-strike, dota, tanks"}, + "web_5": {"name": "Абонемент на 8 занятий (Индивидуальный)", "price": "5 990 ₽", "price_numeric": 5990.0, "description": "counter-strike, dota, tanks"} + } + for sid, svc in web_services.items(): + cursor.execute("SELECT COUNT(*) FROM services WHERE id = ?", (sid,)) + if cursor.fetchone()[0] == 0: + cursor.execute( + "INSERT INTO services (id, name, price, price_numeric, description) VALUES (?, ?, ?, ?, ?)", + (sid, svc["name"], svc["price"], svc["price_numeric"], svc["description"]) + ) + + # Table for web registrations with payments support + cursor.execute(""" + CREATE TABLE IF NOT EXISTS web_registrations ( + id TEXT PRIMARY KEY, + name TEXT, + phone TEXT, + email TEXT, + vk_link TEXT, + agree_oferta INTEGER, + agree_policy INTEGER, + agree_newsletter INTEGER, + service_name TEXT, + amount REAL, + payment_status TEXT DEFAULT 'pending', + yookassa_payment_id TEXT, + paid_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Run migration on web_registrations to add new columns if they don't exist + for col, col_type in [ + ("service_name", "TEXT"), + ("amount", "REAL"), + ("payment_status", "TEXT DEFAULT 'pending'"), + ("yookassa_payment_id", "TEXT"), + ("paid_at", "TIMESTAMP") + ]: + try: + cursor.execute(f"ALTER TABLE web_registrations ADD COLUMN {col} {col_type}") + except sqlite3.OperationalError: + pass + + conn.commit() + + def get_services(self) -> Dict[str, Dict[str, Any]]: + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT id, name, price, price_numeric, description FROM services WHERE is_active = 1") + rows = cursor.fetchall() + services = {} + for row in rows: + services[row["id"]] = { + "name": row["name"], + "price": row["price"], + "price_numeric": row["price_numeric"] if "price_numeric" in row.keys() else 0.0, + "description": row["description"] + } + return services + + def add_web_registration(self, reg_id: str, data: dict): + """Сохранить заявку с веб-формы в базу данных SQLite.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO web_registrations ( + id, name, phone, email, vk_link, + agree_oferta, agree_policy, agree_newsletter, + service_name, amount, payment_status, yookassa_payment_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + reg_id, + data.get("name", "").strip(), + data.get("phone", "").strip(), + data.get("email", "").strip(), + data.get("vk", "").strip(), + 1 if data.get("agreeOferta") else 0, + 1 if data.get("agreePolicy") else 0, + 1 if data.get("agreeNewsletter") else 0, + data.get("serviceName", "Разовая тренировка").strip(), + float(data.get("servicePrice", 870)), + data.get("paymentStatus", "pending"), + data.get("yookassaPaymentId", None) + )) + conn.commit() + + def update_web_payment_status(self, reg_id: str, status: str, yookassa_payment_id: str = None, paid_at: str = None): + """Обновить статус оплаты и сопутствующие данные для заявки.""" + with self._get_connection() as conn: + cursor = conn.cursor() + if yookassa_payment_id and paid_at: + cursor.execute(""" + UPDATE web_registrations + SET payment_status = ?, yookassa_payment_id = ?, paid_at = ? + WHERE id = ? + """, (status, yookassa_payment_id, paid_at, reg_id)) + elif yookassa_payment_id: + cursor.execute(""" + UPDATE web_registrations + SET payment_status = ?, yookassa_payment_id = ? + WHERE id = ? + """, (status, yookassa_payment_id, reg_id)) + else: + cursor.execute(""" + UPDATE web_registrations + SET payment_status = ? + WHERE id = ? + """, (status, reg_id)) + conn.commit() + + def get_web_registration(self, reg_id: str) -> Optional[Dict[str, Any]]: + """Получить детали одной веб-регистрации.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM web_registrations WHERE id = ?", (reg_id,)) + row = cursor.fetchone() + return dict(row) if row else None + + def get_all_web_registrations(self) -> list: + """Получить все веб-регистрации для админ-панели.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM web_registrations ORDER BY created_at DESC") + rows = cursor.fetchall() + return [dict(row) for row in rows] + + def get_all_services_list(self) -> list: + """Получить полный список всех услуг (включая неактивные) для админ-панели.""" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT id, name, price, price_numeric, description, is_active FROM services") + rows = cursor.fetchall() + return [dict(row) for row in rows] + + def add_service(self, service_id: str, name: str, price: str, price_numeric: float, description: str) -> bool: + """Добавить новую услугу в базу данных.""" + with self._get_connection() as conn: + cursor = conn.cursor() + try: + cursor.execute(""" + INSERT INTO services (id, name, price, price_numeric, description, is_active) + VALUES (?, ?, ?, ?, ?, 1) + """, (service_id.strip(), name.strip(), price.strip(), float(price_numeric), description.strip())) + conn.commit() + return True + except sqlite3.IntegrityError: + return False + + def update_service(self, service_id: str, name: str, price: str, price_numeric: float, description: str, is_active: int) -> bool: + """Обновить поля существующей услуги по её ID.""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE services + SET name = ?, price = ?, price_numeric = ?, description = ?, is_active = ? + WHERE id = ? + """, (name.strip(), price.strip(), float(price_numeric), description.strip(), int(is_active), service_id.strip())) + conn.commit() + return cursor.rowcount > 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1896d7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + ikp-web: + build: . + container_name: ikp-site-container + restart: unless-stopped + ports: + - "8000:8000" + env_file: + - .env + volumes: + # Постоянное сохранение БД SQLite на хосте + - ./bot_database.db:/app/bot_database.db + # Постоянное сохранение Excel-файла с заявками + - ./form/registration_for_training.xlsx:/app/form/registration_for_training.xlsx diff --git a/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf b/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf new file mode 100644 index 0000000..9d5fd84 Binary files /dev/null and b/form/Dogovor_oferty_kibersport_versia_izmenennaya.pdf differ diff --git a/form/fix_excel.py b/form/fix_excel.py new file mode 100644 index 0000000..4775fe3 --- /dev/null +++ b/form/fix_excel.py @@ -0,0 +1,73 @@ +import os +import openpyxl +from openpyxl.styles import Alignment + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +FILE_NAME = os.path.join(SCRIPT_DIR, "registration_for_training.xlsx") + +def fix_shifted_rows(): + if not os.path.exists(FILE_NAME): + print(f"Файл {FILE_NAME} не найден.") + return + + try: + wb = openpyxl.load_workbook(FILE_NAME) + ws = wb.active + except PermissionError: + print("\n[ОШИБКА] Доступ запрещен. Пожалуйста, ЗАКРОЙТЕ файл 'registration_for_training.xlsx' в Excel или других программах и запустите скрипт снова.\n") + return + except Exception as e: + print(f"Ошибка при загрузке файла: {e}") + return + + rows = list(ws.iter_rows(values_only=False)) + if len(rows) <= 1: + print("В таблице нет данных для исправления (только заголовки).") + return + + changed = False + for i in range(1, len(rows)): + row_num = i + 1 + id_val = ws.cell(row=row_num, column=1).value + + # If the first cell doesn't start with 'id_', it means this row was written + # by the old running server process and needs to be shifted to the right. + if id_val and not str(id_val).startswith("id_"): + print(f"Исправляем смещение в строке {row_num} для пользователя: '{id_val}'") + + # Read the 8 fields that were written by the old server + old_values = [ws.cell(row=row_num, column=c).value for c in range(1, 9)] + + # Re-format this row: Column 1 is id_001, columns 2-9 are the shifted fields + ws.cell(row=row_num, column=1, value="id_001") + for col_idx, val in enumerate(old_values, 2): + ws.cell(row=row_num, column=col_idx, value=val) + + # Clear column 10 in case there is any residue + ws.cell(row=row_num, column=10, value=None) + + # Re-apply styles and alignments + data_align_left = Alignment(horizontal="left", vertical="center") + data_align_center = Alignment(horizontal="center", vertical="center") + + for col_idx in range(1, 10): + cell = ws.cell(row=row_num, column=col_idx) + if col_idx in [2, 4, 5]: + cell.alignment = data_align_left + else: + cell.alignment = data_align_center + + changed = True + + if changed: + try: + wb.save(FILE_NAME) + print("\n[УСПЕХ] Все смещенные строки в Excel были автоматически исправлены!") + print(f"Файл успешно сохранен: {FILE_NAME}\n") + except PermissionError: + print("\n[ОШИБКА] Не удалось сохранить файл. Пожалуйста, убедитесь, что файл закрыт в Excel, и попробуйте снова.\n") + else: + print("Смещенных строк не обнаружено. Все данные соответствуют формату.") + +if __name__ == "__main__": + fix_shifted_rows() diff --git a/form/index.html b/form/index.html new file mode 100644 index 0000000..e0b76cd --- /dev/null +++ b/form/index.html @@ -0,0 +1,169 @@ + + + + + + + + ИКП. Онлайн тренировки по киберспорту | Заявка на бесплатный урок + + + + + + + + + + + + +
+
+ +
+
+ ИКП Киберспорт +
+ +

ИКП. Онлайн тренировки по киберспорту

+

Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП

+ +
+ + + + + + + +
+ +
+ + 👤 +
+
+ ⚠️ Пожалуйста, введите ваше имя +
+
+ + +
+ +
+ + 📞 +
+
+ ⚠️ Введите корректный номер телефона: +7 (999) 999-99-99 +
+
+ + +
+ +
+ + ✉️ +
+
+ ⚠️ Введите корректный e-mail (например, name@mail.ru) +
+
+ + +
+ +
+ + 🌐 +
+
+ ⚠️ Ссылка должна вести на vk.com или vkontakte.ru +
+
+ +
+ +
+ ⚠️ Вы должны согласиться с офертой +
+
+ +
+ +
+ ⚠️ Вы должны согласиться с политикой обработки данных +
+
+ +
+ +
+ + + + + + +
+
+ + + +
+
+ + + + + + \ No newline at end of file diff --git a/form/script.js b/form/script.js new file mode 100644 index 0000000..9625892 --- /dev/null +++ b/form/script.js @@ -0,0 +1,384 @@ +document.addEventListener('DOMContentLoaded', () => { + const nameInput = document.getElementById('name'); + const phoneInput = document.getElementById('phone'); + const emailInput = document.getElementById('email'); + const vkInput = document.getElementById('vk'); + const agreeOfertaInput = document.getElementById('agreeOferta'); + const agreePolicyInput = document.getElementById('agreePolicy'); + const agreeNewsletterInput = document.getElementById('agreeNewsletter'); + + const form = document.getElementById('ikpForm'); + const formContainer = document.getElementById('formContainer'); + const successContainer = document.getElementById('successContainer'); + const resetBtn = document.getElementById('resetBtn'); + + // Service display elements + const serviceDisplay = document.getElementById('serviceDisplay'); + const serviceNameDisplay = document.getElementById('serviceNameDisplay'); + const servicePriceDisplay = document.getElementById('servicePriceDisplay'); + const serviceIdInput = document.getElementById('serviceId'); + + // Parse URL query parameters + const urlParams = new URLSearchParams(window.location.search); + const serviceIdParam = urlParams.get('service_id'); + const successParam = urlParams.get('success'); + + // If redirected back from payment successfully + if (successParam === 'true') { + formContainer.style.display = 'none'; + successContainer.style.display = 'flex'; + successContainer.style.opacity = '1'; + } + + let services = {}; + + const servicesUrl = window.location.protocol === 'file:' ? 'http://localhost:8000/api/services' : '/api/services'; + + fetch(servicesUrl) + .then(response => { + if (!response.ok) { + throw new Error('Не удалось загрузить список услуг'); + } + return response.json(); + }) + .then(data => { + if (data.status === 'success') { + services = data.services; + initializeServiceDisplay(); + } else { + console.error('Error loading services:', data.message); + fallbackServiceDisplay(); + } + }) + .catch(err => { + console.error('Error fetching services:', err); + fallbackServiceDisplay(); + }); + + function initializeServiceDisplay() { + if (serviceIdParam && services[serviceIdParam]) { + const selectedService = services[serviceIdParam]; + serviceNameDisplay.textContent = selectedService.name; + const price = selectedService.price_numeric || parseFloat(selectedService.price) || 0; + servicePriceDisplay.textContent = price.toLocaleString('ru-RU') + ' ₽'; + serviceIdInput.value = serviceIdParam; + serviceDisplay.style.display = 'block'; + } else { + // Default to web_1 + serviceIdInput.value = "web_1"; + if (services["web_1"]) { + const selectedService = services["web_1"]; + serviceNameDisplay.textContent = selectedService.name; + const price = selectedService.price_numeric || parseFloat(selectedService.price) || 0; + servicePriceDisplay.textContent = price.toLocaleString('ru-RU') + ' ₽'; + serviceDisplay.style.display = 'block'; + } + } + } + + function fallbackServiceDisplay() { + services = { + "web_1": { name: "Разовая тренировка", price_numeric: 870 }, + "web_2": { name: "Абонемент на 4 занятия (Групповой)", price_numeric: 2790 }, + "web_3": { name: "Абонемент на 8 занятий (Групповой)", price_numeric: 4990 }, + "web_4": { name: "Абонемент на 4 занятия (Индивидуальный)", price_numeric: 3290 }, + "web_5": { name: "Абонемент на 8 занятий (Индивидуальный)", price_numeric: 5990 } + }; + initializeServiceDisplay(); + } + + // List of fields to manage focus and validation state + const fields = [ + { input: nameInput, check: checkName, dirty: false }, + { input: phoneInput, check: checkPhone, dirty: false }, + { input: emailInput, check: checkEmail, dirty: false }, + { input: vkInput, check: checkVK, dirty: false }, + { input: agreeOfertaInput, check: checkOferta, dirty: false }, + { input: agreePolicyInput, check: checkPolicy, dirty: false } + ]; + + /* ==================================================== + Validation Functions + ==================================================== */ + function showError(input, show) { + const group = input.closest('.form-group') || input.closest('.checkbox-group'); + if (show) { + group.classList.add('has-error'); + } else { + group.classList.remove('has-error'); + } + } + + function checkName() { + const val = nameInput.value.trim(); + const isValid = val.length >= 2; + showError(nameInput, !isValid); + return isValid; + } + + function checkPhone() { + const val = phoneInput.value; + const digits = val.replace(/\D/g, ''); + const isValid = digits.length === 11 && val.startsWith('+7') && val.length === 18; + showError(phoneInput, !isValid); + return isValid; + } + + function checkEmail() { + const val = emailInput.value.trim(); + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + const isValid = emailRegex.test(val); + showError(emailInput, !isValid); + return isValid; + } + + function checkVK() { + const val = vkInput.value.trim().toLowerCase(); + const isValid = val.length > 0; + showError(vkInput, !isValid); + return isValid; + } + + if (agreeOfertaInput) { + agreeOfertaInput.addEventListener('change', checkOferta); + } + if (agreePolicyInput) { + agreePolicyInput.addEventListener('change', checkPolicy); + } + + function checkOferta() { + const isValid = agreeOfertaInput.checked; + showError(agreeOfertaInput, !isValid); + return isValid; + } + + function checkPolicy() { + const isValid = agreePolicyInput.checked; + showError(agreePolicyInput, !isValid); + return isValid; + } + + /* ==================================================== + Interaction Listeners (UX enhancements) + ==================================================== */ + fields.forEach(field => { + field.input.addEventListener('blur', () => { + field.dirty = true; + field.check(); + }); + + const eventType = field.input.type === 'checkbox' ? 'change' : 'input'; + field.input.addEventListener(eventType, () => { + if (field.dirty) { + field.check(); + } + }); + }); + + /* ==================================================== + Phone Input Mask Implementation + ==================================================== */ + phoneInput.addEventListener('input', (e) => { + let value = e.target.value; + let digits = value.replace(/\D/g, ''); + + if (digits.startsWith('7') || digits.startsWith('8')) { + digits = digits.substring(1); + } + + digits = digits.substring(0, 10); + + let formatted = ''; + if (digits.length > 0) { + formatted = '+7 (' + digits.substring(0, 3); + } + if (digits.length >= 3) { + formatted += ') '; + } + if (digits.length > 3) { + formatted += digits.substring(3, 6); + } + if (digits.length > 6) { + formatted += '-' + digits.substring(6, 8); + } + if (digits.length > 8) { + formatted += '-' + digits.substring(8, 10); + } + + e.target.value = formatted; + + const phoneField = fields.find(item => item.input === phoneInput); + if (phoneField && phoneField.dirty) { + checkPhone(); + } + }); + + phoneInput.addEventListener('keydown', (e) => { + if (e.key === 'Backspace') { + const val = e.target.value; + if (val === '+7 ' || val === '+7' || val === '+') { + e.target.value = ''; + } + } + }); + + phoneInput.addEventListener('focus', (e) => { + if (!e.target.value) { + e.target.value = '+7 '; + } + }); + + phoneInput.addEventListener('blur', (e) => { + if (e.target.value === '+7 ' || e.target.value === '+7') { + e.target.value = ''; + } + const phoneField = fields.find(item => item.input === phoneInput); + if (phoneField) { + phoneField.dirty = true; + checkPhone(); + } + }); + + /* ==================================================== + Form Submission + ==================================================== */ + form.addEventListener('submit', (e) => { + e.preventDefault(); + + fields.forEach(field => { + field.dirty = true; + }); + + const isNameValid = checkName(); + const isPhoneValid = checkPhone(); + const isEmailValid = checkEmail(); + const isVKValid = checkVK(); + const isOfertaValid = checkOferta(); + const isPolicyValid = checkPolicy(); + + if (isNameValid && isPhoneValid && isEmailValid && isVKValid && isOfertaValid && isPolicyValid) { + const formData = { + name: nameInput.value.trim(), + phone: phoneInput.value, + email: emailInput.value.trim(), + vk: vkInput.value.trim(), + agreeOferta: agreeOfertaInput.checked, + agreePolicy: agreePolicyInput.checked, + agreeNewsletter: agreeNewsletterInput.checked, + serviceId: serviceIdInput.value || "web_1" + }; + + const submitBtn = document.getElementById('submitBtn'); + const originalBtnText = submitBtn.textContent; + submitBtn.disabled = true; + submitBtn.textContent = 'Отправка...'; + + const submitUrl = window.location.protocol === 'file:' ? 'http://localhost:8000/submit' : '/submit'; + + fetch(submitUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }) + .then(response => { + return response.json().then(data => { + if (!response.ok) { + throw new Error(data.message || 'Ошибка сервера'); + } + return data; + }).catch(err => { + if (!response.ok) { + throw new Error('Ошибка сети или сервера'); + } + throw err; + }); + }) + .then(data => { + if (data.payment_url) { + // Redirect directly to YooKassa payment gateway + window.location.href = data.payment_url; + return; + } + + // Fallback success UI container (if YooKassa keys not configured in server) + formContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + formContainer.style.opacity = '0'; + formContainer.style.transform = 'translateY(-10px)'; + + setTimeout(() => { + formContainer.style.display = 'none'; + successContainer.style.display = 'flex'; + successContainer.style.opacity = '0'; + successContainer.style.transform = 'translateY(10px)'; + + successContainer.offsetHeight; + + successContainer.style.transition = 'opacity 0.4s ease, transform 0.4s ease'; + successContainer.style.opacity = '1'; + successContainer.style.transform = 'translateY(0)'; + }, 300); + + form.reset(); + fields.forEach(field => { + field.dirty = false; + }); + + serviceDisplay.style.display = 'none'; + }) + .catch(error => { + console.error('Error submitting form:', error); + alert('Произошла ошибка при отправке заявки: ' + error.message + '. Пожалуйста, убедитесь, что сервер server.py запущен.'); + }) + .finally(() => { + submitBtn.disabled = false; + submitBtn.textContent = originalBtnText; + }); + } else { + const firstInvalid = fields.find(field => { + if (field.input === nameInput) return !isNameValid; + if (field.input === phoneInput) return !isPhoneValid; + if (field.input === emailInput) return !isEmailValid; + if (field.input === vkInput) return !isVKValid; + if (field.input === agreeOfertaInput) return !isOfertaValid; + if (field.input === agreePolicyInput) return !isPolicyValid; + return false; + }); + + if (firstInvalid) { + firstInvalid.input.focus(); + } + } + }); + + /* ==================================================== + Reset / Back to Form Handler + ==================================================== */ + resetBtn.addEventListener('click', () => { + successContainer.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + successContainer.style.opacity = '0'; + successContainer.style.transform = 'translateY(10px)'; + + // Remove parameters from URL to prevent showing success container on reload + const newUrl = window.location.protocol + "//" + window.location.host + window.location.pathname + "?service_id=" + serviceIdInput.value; + window.history.replaceState({ path: newUrl }, '', newUrl); + + setTimeout(() => { + successContainer.style.display = 'none'; + formContainer.style.display = 'block'; + formContainer.style.opacity = '0'; + formContainer.style.transform = 'translateY(-10px)'; + + formContainer.offsetHeight; + + formContainer.style.transition = 'opacity 0.4s ease, transform 0.4s ease'; + formContainer.style.opacity = '1'; + formContainer.style.transform = 'translateY(0)'; + + if (serviceIdInput.value && services[serviceIdInput.value]) { + serviceDisplay.style.display = 'block'; + } + }, 300); + }); +}); diff --git a/form/style.css b/form/style.css new file mode 100644 index 0000000..cb613fb --- /dev/null +++ b/form/style.css @@ -0,0 +1,538 @@ +/* ---------------------------------------------------- + 1. DESIGN SYSTEM & RESET + ---------------------------------------------------- */ +:root { + --bg-color: #0A0F1F; + --card-bg: rgba(16, 22, 42, 0.85); + --primary-orange: #ff7a00; + --primary-orange-hover: #ff9233; + --cyan-accent: #00d2ff; + --text-white: #ffffff; + --text-gray: #8f9cae; + --text-dark: #0A0F1F; + --error-red: #ff4a4a; + --input-border: rgba(255, 255, 255, 0.12); + --font-headings: 'Montserrat', sans-serif; + --font-body: 'Inter', sans-serif; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: var(--bg-color); + /* Dark gradient overlay + brand background image from help_img */ + background-image: + radial-gradient(circle at center, rgba(13, 25, 47, 0.93) 0%, rgba(10, 15, 31, 0.99) 100%), + url('../help_img/bg.jpg'); + background-repeat: no-repeat; + background-position: center center; + background-attachment: fixed; + background-size: cover; + color: var(--text-white); + font-family: var(--font-body); + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + padding: 24px 16px; + overflow-x: hidden; +} + +/* ---------------------------------------------------- + 2. LAYOUT & CARD CONTAINER + ---------------------------------------------------- */ +main { + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.card { + background: var(--card-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 24px; + padding: 40px; + width: 100%; + max-width: 480px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6), + inset 0 1px 1px rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; + transition: all 0.3s ease; +} + +/* Premium Esports Accent Line at the top of the card */ +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--cyan-accent) 0%, var(--primary-orange) 100%); +} + +/* ---------------------------------------------------- + 3. BRANDING & HEADER + ---------------------------------------------------- */ +.logo-container { + display: flex; + justify-content: center; + margin-bottom: 24px; +} + +.logo-img { + height: 110px; + width: auto; + object-fit: contain; + border: 2px solid rgba(255, 122, 0, 0.25); + border-radius: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + transition: transform 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; +} + +.logo-img:hover { + transform: scale(1.04); + border-color: var(--primary-orange); + box-shadow: 0 8px 28px rgba(255, 122, 0, 0.4); +} + +.form-title { + font-family: var(--font-headings); + font-weight: 800; + font-size: 1.6rem; + text-transform: uppercase; + text-align: center; + margin: 0 0 10px 0; + background: linear-gradient(90deg, #ffffff 50%, var(--text-gray) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + letter-spacing: 0.5px; + line-height: 1.3; +} + +.form-subtitle { + font-size: 0.92rem; + color: var(--text-gray); + text-align: center; + margin: 0 0 32px 0; + line-height: 1.5; +} + +/* ---------------------------------------------------- + 4. FORM INPUTS & ELEMENTS + ---------------------------------------------------- */ +.form-group { + margin-bottom: 22px; + position: relative; +} + +.form-label { + display: block; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--cyan-accent); + margin-bottom: 8px; +} + +.required-star { + color: var(--primary-orange); +} + +.input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.form-input { + width: 100%; + height: 52px; + padding: 0 16px 0 44px; + background: rgba(10, 15, 31, 0.7); + border: 1.5px solid var(--input-border); + border-radius: 12px; + color: var(--text-white); + font-family: var(--font-body); + font-size: 0.95rem; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} + +.form-input::placeholder { + color: #4a5c73; +} + +.form-input:focus { + outline: none; + border-color: var(--primary-orange); + background: rgba(10, 15, 31, 0.85); + box-shadow: 0 0 14px rgba(255, 122, 0, 0.2); +} + +.input-icon { + position: absolute; + left: 16px; + font-size: 1.1rem; + color: #4a5c73; + pointer-events: none; + transition: color 0.25s ease; +} + +.form-input:focus + .input-icon { + color: var(--primary-orange); +} + +/* ---------------------------------------------------- + 5. ERROR HANDLING + ---------------------------------------------------- */ +.error-message { + color: var(--error-red); + font-size: 0.8rem; + margin-top: 6px; + display: none; + align-items: center; + gap: 6px; + font-weight: 500; + animation: slideDown 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Error States for Inputs */ +.form-group.has-error .form-input { + border-color: var(--error-red); + box-shadow: 0 0 8px rgba(255, 74, 74, 0.15); +} + +.form-group.has-error .input-icon { + color: var(--error-red); +} + +.form-group.has-error .error-message { + display: flex; +} + +/* ---------------------------------------------------- + 6. BUTTONS & ACTIONS + ---------------------------------------------------- */ +.submit-btn { + width: 100%; + height: 54px; + background: var(--primary-orange); + border: none; + border-radius: 12px; + color: var(--text-dark); + font-family: var(--font-headings); + font-weight: 800; + font-size: 1.05rem; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 4px 12px rgba(255, 122, 0, 0.2); + margin-top: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.submit-btn:hover { + background: var(--primary-orange-hover); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(255, 122, 0, 0.4); +} + +.submit-btn:active { + transform: translateY(0); + box-shadow: 0 4px 12px rgba(255, 122, 0, 0.2); +} + +.submit-btn:focus-visible { + outline: 2px solid var(--text-white); + outline-offset: 2px; +} + +/* ---------------------------------------------------- + 7. LEGAL CONSENT + ---------------------------------------------------- */ +.consent-text { + font-size: 0.72rem; + color: var(--text-gray); + text-align: center; + margin-top: 22px; + line-height: 1.5; +} + +.consent-link { + color: var(--cyan-accent); + text-decoration: none; + border-bottom: 1px dashed rgba(0, 210, 255, 0.3); + transition: all 0.2s ease; +} + +.consent-link:hover { + color: var(--primary-orange); + border-bottom-color: rgba(255, 122, 0, 0.5); +} + +/* ---------------------------------------------------- + 8. SUCCESS STATE WINDOW + ---------------------------------------------------- */ +.success-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 10px 0; + animation: scaleIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; +} + +@keyframes scaleIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +.success-icon-wrapper { + width: 80px; + height: 80px; + border-radius: 50%; + background: rgba(0, 210, 255, 0.1); + border: 3px solid var(--cyan-accent); + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 24px; + box-shadow: 0 0 20px rgba(0, 210, 255, 0.25); +} + +.success-icon { + font-size: 2.5rem; + color: var(--cyan-accent); + font-weight: bold; +} + +.success-title { + font-family: var(--font-headings); + font-size: 1.7rem; + font-weight: 800; + text-transform: uppercase; + color: var(--primary-orange); + margin: 0 0 16px 0; + letter-spacing: 0.5px; +} + +.success-text { + font-size: 0.95rem; + color: #b8c7db; + line-height: 1.6; + margin: 0 0 32px 0; +} + +.reset-btn { + width: 100%; + height: 52px; + background: transparent; + border: 2px solid rgba(255, 255, 255, 0.15); + border-radius: 12px; + color: var(--text-white); + font-family: var(--font-headings); + font-weight: 700; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 1px; + cursor: pointer; + transition: all 0.25s ease; +} + +.reset-btn:hover { + border-color: var(--cyan-accent); + color: var(--cyan-accent); + background: rgba(0, 210, 255, 0.05); + box-shadow: 0 0 12px rgba(0, 210, 255, 0.15); +} + +/* ---------------------------------------------------- + 10. CUSTOM CHECKBOXES + ---------------------------------------------------- */ +.checkbox-group { + margin-bottom: 18px; + position: relative; +} + +.checkbox-label { + display: flex; + align-items: flex-start; + cursor: pointer; + user-select: none; + font-size: 0.85rem; + color: var(--text-gray); + line-height: 1.4; +} + +/* Hide default checkbox */ +.checkbox-label input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +/* Custom checkbox indicator */ +.checkbox-custom { + flex-shrink: 0; + height: 20px; + width: 20px; + background: rgba(10, 15, 31, 0.7); + border: 1.5px solid var(--input-border); + border-radius: 6px; + margin-right: 12px; + position: relative; + transition: all 0.2s ease; + margin-top: 1px; +} + +/* On hover */ +.checkbox-label:hover .checkbox-custom { + border-color: var(--cyan-accent); + box-shadow: 0 0 8px rgba(0, 210, 255, 0.2); +} + +/* Checked state background */ +.checkbox-label input[type="checkbox"]:checked ~ .checkbox-custom { + background: var(--primary-orange); + border-color: var(--primary-orange); + box-shadow: 0 0 10px rgba(255, 122, 0, 0.3); +} + +/* Custom checkmark symbol */ +.checkbox-custom::after { + content: ''; + position: absolute; + display: none; + left: 6px; + top: 2px; + width: 5px; + height: 10px; + border: solid var(--text-dark); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +/* Show checkmark when checked */ +.checkbox-label input[type="checkbox"]:checked ~ .checkbox-custom::after { + display: block; +} + +/* Focus outline for accessibility */ +.checkbox-label input[type="checkbox"]:focus-visible ~ .checkbox-custom { + outline: 2px solid var(--text-white); + outline-offset: 2px; +} + +/* Error States for Checkboxes */ +.checkbox-group.has-error .checkbox-custom { + border-color: var(--error-red); + box-shadow: 0 0 8px rgba(255, 74, 74, 0.2); +} +.checkbox-group.has-error .error-message { + display: flex; + margin-left: 32px; /* Aligns error text with checkbox text label */ +} + +/* ---------------------------------------------------- + 9. RESPONSIVE DESIGN + ---------------------------------------------------- */ +@media (max-width: 480px) { + body { + padding: 16px 8px; + } + .card { + padding: 30px 20px; + border-radius: 20px; + } + .form-title { + font-size: 1.35rem; + } + .form-subtitle { + font-size: 0.85rem; + margin-bottom: 24px; + } + .logo-img { + height: 90px; + } + .form-input { + height: 48px; + font-size: 0.9rem; + } + .submit-btn { + height: 50px; + font-size: 0.95rem; + } +} + +/* ---------------------------------------------------- + 11. SELECTED SERVICE DISPLAY + ---------------------------------------------------- */ +.service-display-badge { + padding: 14px 16px; + background: rgba(0, 210, 255, 0.05); + border: 1.5px dashed rgba(0, 210, 255, 0.3); + border-radius: 12px; + box-shadow: 0 4px 15px rgba(0, 210, 255, 0.05); + animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +.service-badge-content { + display: flex; + align-items: center; + gap: 12px; +} + +.service-icon { + font-size: 1.6rem; + line-height: 1; +} + +.service-details { + display: flex; + flex-direction: column; + gap: 2px; +} + +.service-label { + font-size: 0.65rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-gray); +} + +.service-value { + font-family: var(--font-headings); + font-weight: 800; + font-size: 0.95rem; + color: var(--text-white); +} + +#serviceNameDisplay { + color: var(--cyan-accent); +} + +#servicePriceDisplay { + color: var(--primary-orange); +} + diff --git a/form/Политика ПД.pdf b/form/Политика ПД.pdf new file mode 100644 index 0000000..309ea58 Binary files /dev/null and b/form/Политика ПД.pdf differ diff --git a/index.html b/index.html index 4641178..b7d0127 100644 --- a/index.html +++ b/index.html @@ -287,7 +287,7 @@ class="text-[9px] font-mono text-gray-500 block uppercase tracking-widest">Цена: 870 ₽ - Записаться Цена: 2 790 ₽ - Записаться Цена: 4 990 ₽ - Записаться Цена: 3 290 ₽ - Записаться Цена: 5 990 ₽ - Записаться =3.0.0 +requests>=2.31.0 +openpyxl>=3.1.2 diff --git a/server.py b/server.py new file mode 100644 index 0000000..c6546d0 --- /dev/null +++ b/server.py @@ -0,0 +1,1392 @@ +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, # "Без НДС" (no VAT) + "payment_mode": "full_payment", + "payment_subject": "service", + "agent_type": "bank_paying_agent", + "supplier_info": { + "phone": config.SUPPLIER_PHONE, + "name": config.SUPPLIER_NAME, + "inn": config.SUPPLIER_INN + } + } + ] + } + + 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" + f"🆔 ID заявки: {reg_id}\n" + f"👤 Имя: {data.get('name', '').strip()}\n" + f"📞 Телефон: {data.get('phone', '').strip()}\n" + f"✉️ E-mail: {data.get('email', '').strip()}\n" + f"🌐 Профиль VK: {data.get('vk', '').strip()}\n" + f"🏆 Услуга: {data.get('serviceName', '').strip()}\n" + f"💰 Сумма: {data.get('servicePrice', '')} ₽\n\n" + f"⚖️ Согласия пользователя:\n" + f"• Оферта: {'Да' if data.get('agreeOferta') else 'Нет'}\n" + f"• Политика ПД: {'Да' if data.get('agreePolicy') else 'Нет'}\n" + f"• Рассылка: {'Да' if data.get('agreeNewsletter') else 'Нет'}\n" + ) + + vk.messages.send( + peer_id=operator_id, + message=message, + random_id=get_random_id() + ) + print(f"[VK NOTIFICATION] Отправлено уведомление оператору (ID: {operator_id}) для {reg_id}.") + except Exception as e: + print(f"[VK NOTIFICATION] Ошибка при отправке уведомления в 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 = config.SMTP_TO or smtp_user + + 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""" + + +
+
+

ИКП — Детали заказа

+
+ +

Здравствуйте!

+

На сайте зарегистрирована заявка с новым статусом:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID заявки:{reg_id}
Статус заказа: + + {status_str} + +
Выбранная услуга:{data.get('serviceName')}
Сумма платежа:{data.get('servicePrice')} ₽
Имя клиента:{data.get('name')}
Номер телефона:{data.get('phone')}
E-mail:{data.get('email')}
Профиль VK:{data.get('vk')}
Дата регистрации:{datetime.now().strftime("%d.%m.%Y %H:%M:%S")}
+ +
+ Это автоматическое системное уведомление от ИКП. +
+
+ + + """ + + try: + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = smtp_from + msg['To'] = 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()) + server.quit() + print(f"[SMTP] Письмо о заказе {reg_id} отправлено на {smtp_to}") + return True + except Exception as e: + print(f"[SMTP] Ошибка отправки письма: {e}") + return False + +# ==================================================== +# 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}form/index.html?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 (ЛК Администратора) +# ==================================================== +LOGIN_TEMPLATE = """ + + + + + + ИКП Панель | Авторизация + + + + + +
+
+ +
+
+

ИКП Админ

+

Панель управления оплатами

+
+ + {% if error %} +
+ ⚠️ {{ error }} +
+ {% endif %} + +
+
+ + +
+ + +
+
+ + +""" + +DASHBOARD_TEMPLATE = """ + + + + + + ИКП Админ | Реестр оплат + + + + + +
+
+
+ ИКП + + Панель управления оплат +
+
+ Выйти + +
+
+ +
+ +
+ + +
+ + +
+ +
+
+

Всего заявок

+

{{ stats.total }}

+
+
+

Оплачено заказов

+

{{ stats.paid_count }}

+
+
+

Ожидает оплаты

+

{{ stats.pending_count }}

+
+
+

Сумма сборов

+

{{ stats.total_revenue | int | numberformat }} ₽

+
+
+ + +
+
+ + + +
+ +
+ + 🔍 +
+
+ + +
+
+ + + + + + + + + + + + + + + {% for reg in registrations %} + + + + + + + + + + + {% endfor %} + +
IDКлиентКонтактыУслугаСуммаСтатусРегистрацияДействие
{{ reg.id }} + {{ reg.name }} + +
+ 📞 {{ reg.phone }} +
+
+ ✉️ {{ reg.email }} +
+ {% if reg.vk_link %} + + {% endif %} +
+ {{ reg.service_name }} + {{ reg.amount | int }} ₽ + {% if reg.payment_status == 'paid' %} + + Оплачено + + {% else %} + + Ожидает + + {% endif %} + +
Рег: {{ reg.created_at }}
+ {% if reg.paid_at %} +
Опл: {{ reg.paid_at }}
+ {% endif %} +
+ {% if reg.payment_status != 'paid' and reg.yookassa_payment_id %} + + {% else %} + - + {% endif %} +
+
+
+
+ + + +
+ + + + + + + + + + + + +""" + +@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/', 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/', 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()