+ ИКП. Онлайн тренировки по киберспорту
+Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП
+ + +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 @@ + + + +
+ + + +
+ Оставить заявку на бесплатный урок и доступ к тестовым материалам ИКП
+ + +