diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,6154 @@ + +# -*- coding: utf-8 -*- +from flask import Flask, render_template_string, request, redirect, url_for, jsonify +import json +import os +import logging +import threading +import time +from datetime import datetime, timedelta, timezone +import pytz +from huggingface_hub import HfApi, hf_hub_download +from huggingface_hub.utils import RepositoryNotFoundError +from werkzeug.utils import secure_filename +from werkzeug.security import generate_password_hash, check_password_hash +import jwt # Добавлено для JWT +from functools import wraps # Добавлено для декоратора +import math # Для округления баллов +# from flask import Response # Раскомментируйте, если будете использовать Basic Auth для админки + +app = Flask(__name__) +# Используем SECRET_KEY для подписи JWT. Убедись, что он надежный и хранится в переменных окружения. +app.config['SECRET_KEY'] = os.getenv("FLASK_SECRET_KEY", "your_very_strong_jwt_secret_key_here_CHANGE_ME") # Переименовано для ясности, но может быть то же значение + +DATA_FILE = 'data_exmenu.json' +USER_DATA_FILE = 'data_emirusers.json' + +REPO_ID = "Kgshop/clients" # Замените на ваш реальный Repo ID +HF_TOKEN_WRITE = os.getenv("HF_TOKEN") +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Должен быть токен с правом чтения +LOGO_URL = os.getenv("LOGO_URL","https://huggingface.co/spaces/kgmenu/Emir/resolve/main/emir_chaihana-20250405-0001.jpg") # Используем переменную окружения или дефолт + +# Константы для кешбэка +CASHBACK_PERCENTAGE = 5 # 5% кешбэк + +logging.basicConfig(level=logging.INFO) # Поставил INFO, чтобы не было слишком много логов + +# --- Функции загрузки/сохранения данных (без изменений в основной логике, но с UTC фокусом) --- +def load_data(): + try: + # Пытаемся скачать свежую версию перед загрузкой + try: + download_db_from_hf() + except RepositoryNotFoundError: + logging.error(f"Репозиторий {REPO_ID} не найден при попытке скачивания {DATA_FILE}. Используется локальная версия, если есть.") + except Exception as e: + logging.error(f"Ошибка скачивания {DATA_FILE} с Hugging Face: {e}. Используется локальная версия, если есть.") + + if not os.path.exists(DATA_FILE): + logging.warning(f"Локальный файл {DATA_FILE} не найден. Возвращаем пустую структуру.") + return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} + + with open(DATA_FILE, 'r', encoding='utf-8') as file: + data = json.load(file) + logging.info("Данные меню успешно загружены из локального JSON") + if not isinstance(data, dict) or 'products' not in data or 'categories' not in data: + logging.warning(f"Структура файла {DATA_FILE} некорректна. Сброс к дефолтной.") + data = {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} + if 'stoplist' not in data: data['stoplist'] = {} + if 'qr_code' not in data: data['qr_code'] = None + if 'news' not in data: data['news'] = [] + + # News expiry check + current_time_utc = datetime.now(timezone.utc) + updated_news = [] + for news_item in data.get('news', []): # Используем .get для безопасности + expiry_datetime_utc = None + if isinstance(news_item, dict) and 'expiry' in news_item and news_item['expiry']: + try: + # Убедимся, что время в UTC + dt_str = news_item['expiry'] + dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) # Учитываем 'Z' если есть + + if dt.tzinfo is None: + # Если нет таймзоны, предполагаем UTC (как сохранено) + expiry_datetime_utc = dt.replace(tzinfo=timezone.utc) + else: + expiry_datetime_utc = dt.astimezone(timezone.utc) + + except ValueError: + logging.error(f"Неверный формат даты истечения новости: {news_item['expiry']}") + updated_news.append(news_item) # Сохраняем новость с неверной датой + continue + except TypeError: + logging.error(f"Неверный тип данных для даты истечения новости: {news_item['expiry']}") + updated_news.append(news_item) # Сохраняем новость с неверным типом + continue + + + if expiry_datetime_utc: + if expiry_datetime_utc > current_time_utc: + updated_news.append(news_item) + else: + logging.info(f"Новость '{news_item.get('title', 'N/A')}' истекла и удалена.") + else: + # Keep news without expiry or with invalid expiry for now + updated_news.append(news_item) + + data['news'] = updated_news + + # Преобразуем строки времени стоп-листа обратно в datetime объекты (UTC) + stoplist_processed = {} + current_time_utc_check = datetime.now(timezone.utc) # Используем UTC для сравнения при загрузке + for product_id, stop_info in data.get('stoplist', {}).items(): # Используем .get + if isinstance(stop_info, dict) and 'until' in stop_info: + try: + until_dt_iso = stop_info['until'] + # Убеждаемся что парсим UTC + until_dt = datetime.fromisoformat(until_dt_iso.replace('Z', '+00:00')) + + # Обеспечим UTC таймзону, если вдруг ее нет (хотя isoformat должен давать) + if until_dt.tzinfo is None: + until_dt = until_dt.replace(tzinfo=timezone.utc) + else: + until_dt = until_dt.astimezone(timezone.utc) + + # Сравнение в UTC + if until_dt > current_time_utc_check: + stoplist_processed[str(product_id)] = { # Ключ должен быть строкой + 'until': until_dt, # Храним в UTC datetime + 'minutes': stop_info.get('minutes', 0) # Сохраняем минуты если они были + } + else: + logging.info(f"Запись стоп-листа для продукта {product_id} истекла при загрузке.") + except (ValueError, TypeError) as e: + logging.error(f"Ошибка обработки времени стоп-листа для продукта {product_id} (ISO: {stop_info.get('until')}): {e}. Запись игнорируется.") + else: + logging.warning(f"Некорректная запись стоп-листа для продукта {product_id}: {stop_info}. Запись игнорируется.") + data['stoplist'] = stoplist_processed # Перезаписываем обработанным словарем + + return data + except FileNotFoundError: + logging.warning(f"Локальный файл базы данных меню {DATA_FILE} не найден.") + return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} + except json.JSONDecodeError: + logging.error(f"Ошибка: Невозможно декодировать JSON-файл меню {DATA_FILE}.") + # Возможно, стоит создать бэкап испорченного файла перед возвратом дефолта + return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} + except Exception as e: + logging.error(f"Непредвиденная ошибка при загрузке данных меню: {e}", exc_info=True) # Добавил exc_info + return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} + +def save_data(data): + try: + data_to_save = data.copy() + # Конвертируем datetime стоп-листа в ISO строки (UTC) перед сохранением + data_to_save['stoplist'] = { + pid: { + 'until': info['until'].isoformat(), # datetime уже в UTC + 'minutes': info.get('minutes', 0) + } + for pid, info in data.get('stoplist', {}).items() # Используем .get + if isinstance(info.get('until'), datetime) # Проверяем тип + } + # Новости уже должны быть в ISO формате из логики добавления/загрузки + # Убедимся, что news - это список словарей + news_list = data.get('news', []) + if not isinstance(news_list, list): + logging.warning("Ключ 'news' не является списком при сохранении. Сброс на пустой список.") + news_list = [] + # Доп. проверка типов внутри news + valid_news = [] + for item in news_list: + if isinstance(item, dict): + # Опционально: проверить наличие title, text + valid_news.append(item) + else: + logging.warning(f"Некорректный элемент в списке новостей: {item}. Пропуск при сохранении.") + data_to_save['news'] = valid_news + + + with open(DATA_FILE, 'w', encoding='utf-8') as file: + json.dump(data_to_save, file, ensure_ascii=False, indent=4) + logging.info(f"Данные меню успешно сохранены в {DATA_FILE}") + # Загрузка на HF после локального сохранения + upload_db_to_hf() + except Exception as e: + logging.error(f"Ошибка при сохранении данных меню в {DATA_FILE}: {e}", exc_info=True) + # Не возбуждаем исключение дальше, чтобы приложение не падало, но логируем ошибку + +def upload_db_to_hf(): + if not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. Пропуск загрузки базы данных меню на Hugging Face.") + return + if not os.path.exists(DATA_FILE): + logging.error(f"Файл {DATA_FILE} не найден для загрузки на Hugging Face.") + return + try: + api = HfApi() + api.upload_file( + path_or_fileobj=DATA_FILE, + path_in_repo=DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Автоматическое резервное копирование базы данных меню {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + logging.info(f"Резервная копия {DATA_FILE} загружена на Hugging Face.") + except Exception as e: + logging.error(f"Ошибка при загрузке резервной копии базы данных меню {DATA_FILE}: {e}") + +def download_db_from_hf(): + if not HF_TOKEN_READ: + logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания базы данных меню с Hugging Face.") + return + try: + # Указываем . для текущей директории + downloaded_path = hf_hub_download( + repo_id=REPO_ID, + filename=DATA_FILE, + repo_type="dataset", + token=HF_TOKEN_READ, + local_dir=".", + local_dir_use_symlinks=False, # Важно для многих сред развертывания + force_download=True # Скачивать всегда свежую версию + ) + logging.info(f"JSON-база данных меню {DATA_FILE} загружена с Hugging Face в {downloaded_path}.") + # Переименовываем скачанный файл в DATA_FILE, если имя отличается (hf_hub_download может добавлять хэши) + # Хотя с local_dir='.' и local_dir_use_symlinks=False он должен скачиваться с оригинальным именем + # Но на всякий случай: + # if downloaded_path != DATA_FILE and os.path.exists(downloaded_path): + # os.replace(downloaded_path, DATA_FILE) # Атомарная замена + # logging.info(f"Файл переименован в {DATA_FILE}") + + except RepositoryNotFoundError as e: + logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face (для файла {DATA_FILE}): {e}") + raise # Передаем ошибку выше, чтобы load_data мог её обработать + except Exception as e: + logging.error(f"Ошибка при загрузке JSON-базы данных меню {DATA_FILE} с Hugging Face: {e}", exc_info=True) + raise # Передаем ошибку выше + +# --- Функции для данных пользователей (ИЗМЕНЕНО: регистрация без телефона/адреса) --- +def load_user_data(): + try: + # Пытаемся скачать свежую версию перед загрузкой + try: + download_user_db_from_hf() + except RepositoryNotFoundError: + logging.error(f"Репозиторий {REPO_ID} не найден при попытке скачивания {USER_DATA_FILE}. Используется локальная версия, если есть.") + except Exception as e: + logging.error(f"Ошибка скачивания {USER_DATA_FILE} с Hugging Face: {e}. Используется локальная версия, если есть.") + + if not os.path.exists(USER_DATA_FILE): + logging.warning(f"Локальный файл {USER_DATA_FILE} не найден. Возвращаем пустую структуру.") + return {'users': []} + + with open(USER_DATA_FILE, 'r', encoding='utf-8') as file: + user_data = json.load(file) + logging.info("Данные пользователей успешно загружены из локального JSON") + if not isinstance(user_data, dict) or 'users' not in user_data: + logging.warning(f"Структура файла {USER_DATA_FILE} некорректна. Сброс к дефолтной.") + return {'users': []} + # Дополнительная проверка: убедимся, что users это список + if not isinstance(user_data.get('users'), list): + logging.warning(f"Ключ 'users' в {USER_DATA_FILE} не является списком. Сброс к дефолтной.") + return {'users': []} + # Убедимся, что у всех пользователей есть поле points и order_history + for user in user_data['users']: + if 'points' not in user: + user['points'] = 0 + if 'order_history' not in user: + user['order_history'] = [] + # При старте можно убрать phone/address если их нет, но лучше оставить null + if 'phone' not in user: + user['phone'] = None + if 'address' not in user: + user['address'] = None + + return user_data + except FileNotFoundError: + logging.warning(f"Локальный файл базы данных пользователей {USER_DATA_FILE} не найден.") + return {'users': []} + except json.JSONDecodeError: + logging.error(f"Ошибка: Невозможно декодировать JSON-файл пользователей {USER_DATA_FILE}.") + return {'users': []} + except Exception as e: + logging.error(f"Непредвиденная ошибка при загрузке данных пользователей: {e}", exc_info=True) + return {'users': []} + +def save_user_data(user_data): + try: + # Дополнительная проверка перед сохранением + if not isinstance(user_data, dict) or not isinstance(user_data.get('users'), list): + logging.error(f"Попытка сохранить некорректные данные пользователей: {user_data}. Сохранение отменено.") + return + + with open(USER_DATA_FILE, 'w', encoding='utf-8') as file: + json.dump(user_data, file, ensure_ascii=False, indent=4) + logging.info(f"Данные пользователей успешно сохранены в {USER_DATA_FILE}") + # Загрузка на HF после локального сохранения + upload_user_db_to_hf() + except Exception as e: + logging.error(f"Ошибка при сохранении данных пользователей в {USER_DATA_FILE}: {e}", exc_info=True) + +def upload_user_db_to_hf(): + if not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. Пропуск загрузки базы данных пользователей на Hugging Face.") + return + if not os.path.exists(USER_DATA_FILE): + logging.error(f"Файл {USER_DATA_FILE} не найден для загрузки на Hugging Face.") + return + try: + api = HfApi() + api.upload_file( + path_or_fileobj=USER_DATA_FILE, + path_in_repo=USER_DATA_FILE, + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Автоматическое резервное копирование базы данных пользователей {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + logging.info(f"Резервная копия {USER_DATA_FILE} загружена на Hugging Face.") + except Exception as e: + logging.error(f"Ошибка при загрузке резервной копии базы данных пользователей {USER_DATA_FILE}: {e}") + +def download_user_db_from_hf(): + if not HF_TOKEN_READ: + logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания базы данных пользователей с Hugging Face.") + return + try: + downloaded_path = hf_hub_download( + repo_id=REPO_ID, + filename=USER_DATA_FILE, + repo_type="dataset", + token=HF_TOKEN_READ, + local_dir=".", + local_dir_use_symlinks=False, + force_download=True + ) + logging.info(f"JSON-база данных пользователей {USER_DATA_FILE} загружена с Hugging Face в {downloaded_path}.") + # Переименование, если нужно + # if downloaded_path != USER_DATA_FILE and os.path.exists(downloaded_path): + # os.replace(downloaded_path, USER_DATA_FILE) + # logging.info(f"Файл переименован в {USER_DATA_FILE}") + except RepositoryNotFoundError as e: + logging.error(f"Репозиторий {REPO_ID} не найден (для файла {USER_DATA_FILE}): {e}") + raise + except Exception as e: + logging.error(f"Ошибка при загрузке JSON-базы данных пользователей {USER_DATA_FILE} с Hugging Face: {e}", exc_info=True) + raise + +# --- Периодический бэкап (без изменений) --- +def periodic_backup(): + interval_seconds = 800 # Период бэкапа + logging.info(f"Периодический бэкап настроен с интервалом {interval_seconds} секунд.") + while True: + time.sleep(interval_seconds) + logging.info("Запуск периодического бэкапа...") + try: + # Просто вызываем функции сохранения, которые также вызывают upload_*_to_hf + upload_db_to_hf() # Загружаем файл меню + upload_user_db_to_hf() # Загружаем файл пользователей + + logging.info("Периодический бэкап (загрузка на HF) завершен.") + except Exception as e: + logging.error(f"Ошибка во время периодического бэкапа: {e}", exc_info=True) + +# --- Функции работы с пользователями (ИЗМЕНЕНО: регистрация) --- +def get_category_counts(products): + counts = {} + for product in products: + category = product.get('category', 'Без категории') + counts[category] = counts.get(category, 0) + 1 + return counts + +# ИЗМЕНЕНО: Удалены phone и address из аргументов и new_user +def register_user(login, password): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + if any(user.get('login') == login for user in users): + return False, "Логин уже занят." + if not login or not password: + return False, "Логин и пароль обязательны." + hashed_password = generate_password_hash(password) + new_user = { + 'login': login, + 'password': hashed_password, + 'phone': None, # Изначально пусто + 'address': None, # Изначально пусто + 'points': 0, + 'order_history': [] # Инициализируем историю заказов + } + users.append(new_user) + try: + save_user_data(user_data_dict) + return True, "Регистрация успешна." + except Exception as e: + logging.error(f"Ошибка сохранения данных пользователя при регистрации: {e}") + # Возможно, стоит откатить добавление пользователя, но пока просто вернем ошибку + # users.pop() # Откат + return False, "Ошибка сервера при сохранении данных." + +def authenticate_user(login, password): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user = next((user for user in users if user.get('login') == login), None) + if user and 'password' in user and check_password_hash(user['password'], password): + # Возвращаем копию словаря пользователя без пароля + user_info = user.copy() + del user_info['password'] # Не передаем хэш пароля дальше + return user_info + return None + +# --- Функции работы с профилем пользователя (получение, обновление, баллы, история) --- +# Эти функции теперь будут вызываться из защищенных эндпоинтов, получая 'login' из токена + +def get_user_profile_data(login): # Переименовано для ясности + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user = next((user for user in users if user.get('login') == login), None) + if user: + # Возвращаем копию данных без пароля + profile_data = { + 'login': user.get('login'), + 'phone': user.get('phone'), # Может быть None + 'address': user.get('address'), # Может быть None + 'points': user.get('points', 0), + # 'order_history': user.get('order_history', []) # Историю лучше получать отдельным запросом + } + return profile_data + return None + +def update_user_profile(login, phone, address): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user_found = False + # Добавляем простую валидацию (можно улучшить) + if not phone or not address: + return False, "Телефон и адрес не могут быть пустыми." + + for user in users: + if user.get('login') == login: + user['phone'] = phone + user['address'] = address + user_found = True + break + if user_found: + try: + save_user_data(user_data_dict) + return True, "Профиль обновлен." + except Exception as e: + logging.error(f"Ошибка сохранения данных при обновлении профиля пользователя {login}: {e}") + return False, "Ошибка сервера при сохранении данных." + return False, "Пользователь не найден." + + +# Используется для начисления кешбэка и ВОЗВРАТА БАЛЛОВ ПРИ ОТМЕНЕ +def add_points_to_user(login, points): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user_found = False + # Округляем баллы до целого числа (например, в меньшую сторону или математически) + points_to_add = math.floor(points) # или round(points) + + if points_to_add <= 0: + # При возврате баллов эта проверка не должна срабатывать, т.к. points_to_return > 0 + logging.info(f"Попытка начислить/вернуть <= 0 баллов ({points} -> {points_to_add}) для {login}. Пропуск.") + return True, "Баллы не начислены (сумма < 1)." # Считаем 'успехом', чтобы не блокировать флоу + + for user in users: + if user.get('login') == login: + current_points = user.get('points', 0) + # Убедимся, что points - число + if not isinstance(current_points, (int, float)): + logging.warning(f"Некорректное значение баллов ({current_points}) у пользователя {login}. Сброс на 0 перед добавлением.") + current_points = 0 + user['points'] = current_points + points_to_add + user_found = True + logging.info(f"Пользователю {login} начислено/возвращено {points_to_add} баллов. Новое значение: {user['points']}") + break + if user_found: + try: + save_user_data(user_data_dict) + # Сообщение зависит от контекста вызова (начисление или возврат), но оставим универсальное + return True, f"{points_to_add} баллов успешно начислено/возвращено." + except Exception as e: + logging.error(f"Ошибка сохранения данных при начислении/возврате баллов пользователю {login}: {e}") + return False, "Ошибка сервера при сохранении данных." + return False, "Пользователь не найден." + +# Используется при списании баллов в корзине (при нажатии "Применить") +def redeem_points_from_user(login, points_to_redeem): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user_found = False + + # Округляем списываемые баллы (обычно до целых) + points_to_redeem_int = math.floor(points_to_redeem) # Или round() + + if points_to_redeem_int <= 0: + return False, "Количество баллов для списания должно быть положительным.", 0 + + message = "Пользователь не найден." + success = False + updated_points = 0 # Для возврата нового баланса + + for user in users: + if user.get('login') == login: + user_found = True + current_points = user.get('points', 0) + # Убедимся, что points - число + if not isinstance(current_points, (int, float)): + logging.warning(f"Некорректное значение баллов ({current_points}) у пользователя {login} перед списанием. Считаем как 0.") + current_points = 0 + + if current_points >= points_to_redeem_int: + user['points'] = current_points - points_to_redeem_int + updated_points = user['points'] + try: + save_user_data(user_data_dict) + success = True + message = f"{points_to_redeem_int} баллов успешно списано." + logging.info(f"У пользователя {login} списано {points_to_redeem_int} баллов. Остаток: {updated_points}") + except Exception as e: + logging.error(f"Ошибка сохранения данных при списании баллов у пользователя {login}: {e}") + message = "Ошибка сервера при сохранении данных." + # Откатываем изменение баллов в памяти, если сохранение не удалось + user['points'] = current_points + updated_points = current_points + else: + message = f"Недостаточно баллов для списания. Доступно: {current_points}, требуется: {points_to_redeem_int}." + updated_points = current_points # Баллы не изменились + break # Выходим из цикла после обработки пользователя + + if not user_found: + # Если юзер не найден, получаем его текущие баллы (0) + temp_user_data = get_user_profile_data(login) + updated_points = temp_user_data.get('points', 0) if temp_user_data else 0 + + # Возвращаем статус, сообщение и новый баланс баллов + return success, message, updated_points + +# Сохранение заказа (теперь используется реже, но может быть полезно) +def save_order_to_history(login, order_details): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user_found = False + bishkek_tz = pytz.timezone('Asia/Bishkek') + # Сохраняем время в UTC ISO формате, но с информацией о локальном времени в момент заказа + order_timestamp_utc = datetime.now(timezone.utc).isoformat() + order_timestamp_local = datetime.now(bishkek_tz).strftime('%Y-%m-%d %H:%M:%S %Z%z') + + # Валидация order_details (простой пример) + # 'final_amount' - сумма к оплате после скидки + # 'original_total' - сумма до скидки + # 'redeemed_points' - сколько баллов списано + # 'earned_points' - сколько баллов НАЧИСЛЕНО за этот заказ (логика начисления теперь в другом месте) + required_keys = ['items', 'original_total', 'final_amount', 'redeemed_points', 'delivery_address', 'delivery_time_preference', 'payment_method'] + if not isinstance(order_details, dict) or not all(key in order_details for key in required_keys): + logging.error(f"Некорректные детали заказа для сохранения в историю: {order_details}") + return False, "Некорректные детали заказа." + + order_details_to_save = order_details.copy() + order_details_to_save['timestamp_utc'] = order_timestamp_utc # Для сортировки + order_details_to_save['timestamp_local'] = order_timestamp_local # Для отображения + # Убираем earned_points из сохранения, т.к. начисление происходит отдельно + if 'earned_points' in order_details_to_save: + del order_details_to_save['earned_points'] + + + for user in users: + if user.get('login') == login: + if 'order_history' not in user or not isinstance(user['order_history'], list): + user['order_history'] = [] # Инициализируем, если отсутствует или не список + # Ограничение на размер истории (опционально) + MAX_HISTORY = 50 + user['order_history'].append(order_details_to_save) + if len(user['order_history']) > MAX_HISTORY: + user['order_history'] = user['order_history'][-MAX_HISTORY:] # Оставляем последние N заказов + user_found = True + break + + if user_found: + try: + save_user_data(user_data_dict) + logging.info(f"Заказ сохранен в историю для {login}") + return True, "Заказ сохранен в историю." + except Exception as e: + logging.error(f"Ош��бка сохранения данных при добавлении заказа в историю для {login}: {e}") + return False, "Ошибка сервера при сохранении истории." + return False, "Пользователь не найден." + +def get_order_history(login): + user_data_dict = load_user_data() + users = user_data_dict.get('users', []) + user = next((user for user in users if user.get('login') == login), None) + if user: + # Возвращаем историю, отсортированную по времени (от новых к старым) + history = user.get('order_history', []) + # Сортируем по UTC timestamp для корректности + try: + # Используем timestamp_utc для сортировки + sorted_history = sorted(history, key=lambda x: x.get('timestamp_utc', ''), reverse=True) + return sorted_history + except Exception as e: + logging.error(f"Ошибка сортировки истории заказов для {login}: {e}") + return history # Возвращаем несортированную в случае ошибки + return [] + +# --- JWT Helper --- +def create_access_token(identity): + """Создает JWT токен""" + try: + payload = { + 'exp': datetime.now(timezone.utc) + timedelta(days=30), # Срок действия токена (увеличен до 30 дней) + 'iat': datetime.now(timezone.utc), # Время создания + 'sub': identity # Идентификатор пользователя (login) + } + token = jwt.encode( + payload, + app.config['SECRET_KEY'], + algorithm='HS256' + ) + return token + except Exception as e: + logging.error(f"Ошибка создания JWT: {e}") + return None + +# --- Декоратор для защиты эндпоинтов --- +def token_required(f): + """Декоратор для проверки JWT токена""" + @wraps(f) + def decorated(*args, **kwargs): + token = None + # Ищем токен в заголовке Authorization: Bearer + if 'Authorization' in request.headers: + auth_header = request.headers['Authorization'] + parts = auth_header.split() + if len(parts) == 2 and parts[0].lower() == 'bearer': + token = parts[1] + else: + logging.warning(f"Некорректный формат заголовка Authorization: {auth_header}") + return jsonify({'message': 'Некорректный формат токена в заголовке'}), 401 + # Если токена нет в заголовке, можно попробовать поискать в cookies (но это менее стандартно для API) + # elif 'access_token_cookie' in request.cookies: + # token = request.cookies.get('access_token_cookie') + + if not token: + logging.info("Токен отсутствует в запросе") + return jsonify({'message': 'Токен отсутствует'}), 401 + + try: + # Декодируем и валидируем токен + data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256']) + current_user_login = data.get('sub') + if not current_user_login: + logging.error("Токен не содержит идентификатор пользователя (sub)") + raise jwt.InvalidTokenError("Отсутствует 'sub' в токене") + + # Опционально: Проверяем, существует ли еще такой пользователь в базе + user_profile = get_user_profile_data(current_user_login) + if not user_profile: + logging.warning(f"Пользователь '{current_user_login}' из токена не найден в базе данных.") + # Раскомментируйте, если пользователь должен быть удален при удалении из базы + # return jsonify({'message': 'Пользователь из токена больше не существует'}), 401 + pass # Пока позволяем доступ, даже если пользователь удален + + except jwt.ExpiredSignatureError: + logging.info("Срок действия токена истек") + return jsonify({'message': 'Срок действия токена истек'}), 401 + except jwt.InvalidTokenError as e: + logging.error(f"Ошибка валидации токена: {e}") + return jsonify({'message': 'Недействительный токен'}), 401 + except Exception as e: + logging.error(f"Непредвиденная ошибка при проверке токена: {e}", exc_info=True) + return jsonify({'message': 'Ошибка проверки токена'}), 500 + + # Передаем логин пользователя в декорируемую функцию + return f(current_user_login, *args, **kwargs) + + return decorated + +# --- Маршруты Flask --- + +@app.route('/') +def menu(): + # Загрузка данных как обычно + data = load_data() + products = data.get('products', []) + categories = data.get('categories', []) + # stoplist_raw это теперь словарь {product_id_str: {'until': datetime_utc, 'minutes': int}} + stoplist_raw = data.get('stoplist', {}) + category_counts = get_category_counts(products) + news_list = data.get('news', []) + qr_code_filename = data.get('qr_code') # Имя файла QR-кода + + # Обработка стоп-листа (сравнение в UTC) - теперь stoplist_raw уже содержит datetime + current_time_utc = datetime.now(timezone.utc) + active_stoplist = {} + needs_save = False # Флаг, если нужно пересохранить из-за истекших записей + for product_id, stop_info in stoplist_raw.items(): + # stop_info['until'] уже должен быть datetime UTC благодаря load_data + if isinstance(stop_info.get('until'), datetime): + if stop_info['until'] > current_time_utc: + active_stoplist[product_id] = stop_info + else: + # Эта запись истекла, она не попадет в active_stoplist + needs_save = True # Отмечаем, что были изменения + logging.info(f"Запись стоп-листа для продукта {product_id} истекла и не будет передана в шаблон.") + else: + # Этого не должно быть после load_data, но на всякий случай + logging.warning(f"Некорректная запись (не datetime) в stoplist_raw для ID {product_id}: {stop_info}") + + # Если были найдены истекшие записи, нужно обновить файл data.json + if needs_save: + data['stoplist'] = active_stoplist # Обновляем данные в исходном словаре + save_data(data) # Сохраняем файл с удаленными истекшими записями + + # Форматируем стоп-лист для передачи в шаблон (время в ISO UTC) + stoplist_for_template = { + k: { + 'until': v['until'].isoformat(), # Передаем ISO строку UTC + 'minutes': v.get('minutes', 0) + } + for k, v in active_stoplist.items() + } + + # Сортировка новостей (обрабатываем возможные ошибки с датой) + def get_expiry_datetime(news_item): + expiry_str = news_item.get('expiry') + if expiry_str: + try: + # Парсим ISO строку UTC + dt = datetime.fromisoformat(expiry_str.replace('Z', '+00:00')) + # Убедимся, что TZ = UTC + if dt.tzinfo is None: return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + except (ValueError, TypeError): + return None # Ошибка формата, не можем сортировать по дате + return None + + # Сортируем новости, помещая новости без срока или с ошибкой в конец + now_utc_aware = datetime.now(timezone.utc) + news_for_template = sorted( + news_list, + key=lambda item: get_expiry_datetime(item) or datetime.max.replace(tzinfo=timezone.utc), # None или ошибки идут в конец + reverse=True # Новые в начале + ) + # Дополнительно фильтруем истекшие новости (хотя load_data уже должен был это сделать) + news_for_template = [item for item in news_for_template if not get_expiry_datetime(item) or get_expiry_datetime(item) > now_utc_aware] + + + # ----- ВАЖНО: Изменения для JWT ----- + # Мы больше НЕ проверяем сессию на сервере для отображения интерфейса. + # Фронтенд (JS) будет сам решать, что показывать, на основе наличия токена в localStorage. + # Передаем данные как есть, без информации о логине пользователя на стороне сервера. + # `logged_in`, `user_login`, `user_points`, `user_profile` УДАЛЕНЫ из контекста шаблона. + + # Генерируем полный URL для QR-кода, если он есть + qr_code_url = None + if qr_code_filename: + # URL для доступа к файлу в датасете Hugging Face + qr_code_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{qr_code_filename}" + + # ВАЖНО: Переменная `stoplist` переименована в `stoplist_data` в контексте Jinja, + # чтобы избежать конфликта с функцией `stoplist` в Python. + menu_html = ''' + + + + + + Ресторан Премиум + + + + + + +
+
+ +

Чайхана "Emir"

+

Встречаем с улыбкой, готовим с любовью!

+

Время готовки: 15-30 мин | Доставка: от 30 мин

+ + +
+
+ + {% for category in categories %} + + {% endfor %} +
+ {# Кнопка "Все категории" вынесена за пределы контейнера, чтобы ее можно было фиксировать #} + + +
+ +
+
+ {% for product in products %} +
{# Используем индекс как ID продукта #} + {# Обертка для контента, чтобы клик на ней открывал модалку #} +
+ {% if product.get('photos') and product['photos']|length > 0 %} +
+ {# Добавляем ?t={{ range(1000, 9999) | random }} для сброса кэша HF #} + {{ product['name'] }} +
+ {% endif %} +

{{ product['name'] }}

+
{{ product['price'] }} с
+

{{ product['description'] }}

+
+ {# Блок с кнопками +/- или статусом стоп-листа #} +
+ {# --- JS будет управлять содержимым этого блока --- #} + {# 1. Стоп-лист (приоритет) #} + + {# 2. Кнопки +/- (для залогиненных) #} + + {# 3. Сообщение для НЕзалогиненных #} + +
+
+ {% endfor %} +
+ +
+ + {# Модальное окно продукта (без изменений) #} + + + {# Модальное окно опций (без измене��ий в структуре) #} + + + {# Модальное окно корзины #} + + + {# Модальное окно QR #} + + + + {# Модальное окно профиля #} + + + {# Модальное окно регистрации (ИЗМЕНЕНО: убраны тел/адрес) #} + + + {# Модальное окно входа (без изменений в структуре) #} + + + {# Модальное окно редактирования профиля (теперь только тел/адрес) #} + + + {# Модальное окно истории заказов #} + + + {# Кнопка корзины (управляется JS) #} + + + + + + {# Модальное окно кэшбэка #} + + + {# Модальное окно новостей (без изменений) #} + + + + {# Библиотеки JS #} + {# Обновил jQuery для совместимости с fetch #} + {# Оставил, вдруг пригодится #} + + + {# Основной скрипт с изменениями для JWT #} + + + + ''' + # Передаем данные в шаблон + return render_template_string( + menu_html, + products=products, + categories=categories, + category_counts=category_counts, + stoplist_data=stoplist_for_template, # Передаем обработанный стоп-лист с ISO UTC под именем stoplist_data + repo_id=REPO_ID, + # qr_code=qr_code_filename, # Передаем имя файла + qr_code_url=qr_code_url, # Передаем полный URL + news_for_template=news_for_template, # Отфильтрованные и отсортированные новости + logo_url=LOGO_URL, # Добавил лого URL в контекст + cashback_percentage=CASHBACK_PERCENTAGE # Передаем процент кешбэка + ) +@app.route('/product/') +def product_detail(index): + # Этот эндпоинт остается публичным и не требует изменений + data = load_data() + products = data.get('products', []) + if 0 <= index < len(products): + product = products[index] + else: + # Возвращаем JSON ошибку вместо HTML + return jsonify({'status': 'error', 'message': 'Блюдо не найдено'}), 404 + + # Генерируем HTML только для содержимого модалки + detail_html = ''' + {#
#} {# Убираем container, т.к. это вставляется в модалку #} +

{{ product['name'] }}

+ {# Swiper для фото #} +
+
+ {% if product.get('photos') and product['photos']|length > 0 %} + {% for photo in product['photos'] %} +
+ {# Используем data-src для lazy loading Swiper #} +
+ {{ product['name'] }} +
+
+
+ {% endfor %} + {% else %} +
+ Фото отсутствует +
+ {% endif %} +
+ {# Навигация и пагинация только если фото больше одного #} + {% if product.get('photos') and product['photos']|length > 1 %} +
+
+
+ {% endif %} +
+ {# Детали продукта #} +
{# Добавил внутренний отступ #} +

Категория: {{ product.get('category', 'Без категории') }}

+

Цена: {{ product['price'] }} с

+

Описание:
{{ product['description'] | safe }}

{# Используем safe для HTML #} + {# Отображение опций в модалке #} + {% if product.get('options') and product['options']|length > 0 %} +

Возможные опции:

+
    + {% for option in product['options'] %} +
  • - {{ option.name }} (+{{ option.price or 0 }} c)
  • {# Учитываем, что цена может быть 0 #} + {% endfor %} +
+ {% endif %} +
+ {#
#} {# Закрытие container #} + ''' + return render_template_string(detail_html, product=product, repo_id=REPO_ID) + +# --- Эндпоинты API --- + +# ИЗМЕНЕНО: Убраны phone, address +@app.route('/register', methods=['POST']) +def register(): + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'Некорректный запрос (ожидается JSON)'}), 400 + + login = data.get('login') + password = data.get('password') + # phone = data.get('phone') # УДАЛЕНО + # address = data.get('address') # УДАЛЕНО + + if not login or not password: + return jsonify({'status': 'error', 'message': 'Логин и пароль обязательны'}), 400 + + # ИЗМЕНЕНО: передаем только login, password + success, message = register_user(login, password) + status_code = 201 if success else 400 # 201 Created or 400 Bad Request + return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code + +@app.route('/login', methods=['POST']) +def login(): + # Этот эндпоинт остается публичным + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'Некорректный запрос (ожидается JSON)'}), 400 + + login_attempt = data.get('login') + password_attempt = data.get('password') + + if not login_attempt or not password_attempt: + return jsonify({'status': 'error', 'message': 'Требуется логин и пароль'}), 400 + + user_info = authenticate_user(login_attempt, password_attempt) + + if user_info: + access_token = create_access_token(identity=user_info['login']) + if access_token: + # Возвращаем профиль сразу, чтобы фронтенд не делал лишний запрос + profile_data = get_user_profile_data(user_info['login']) + return jsonify({ + 'status': 'success', + 'access_token': access_token, + 'user_profile': profile_data # Возвращаем профиль при логине + }) + else: + logging.error("Не удалось создать JWT токен после успешной аутентификации.") + return jsonify({'status': 'error', 'message': 'Ошибка сервера при создании сессии'}), 500 + else: + return jsonify({'status': 'error', 'message': 'Неверный логин или пароль'}), 401 # 401 Unauthorized + +# --- Защищенные эндпоинты (требуют JWT) --- + +@app.route('/profile', methods=['GET']) +@token_required +def get_profile(current_user_login): + # Декоратор @token_required уже проверил токен. + # current_user_login содержит логин из токена. + user_data = get_user_profile_data(current_user_login) + if user_data: + return jsonify(user_data) + else: + # Этого не должно произойти, если пользователь существует в токене + logging.error(f"Пользователь {current_user_login} из валидного токена не найден в базе get_profile.") + return jsonify({'message': 'Пользователь не найден'}), 404 + +@app.route('/update_profile', methods=['POST']) +@token_required +def update_profile(current_user_login): + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400 + + phone = data.get('phone', '').strip() # Берем значения, обрезаем пробелы + address = data.get('address', '').strip() + + if not phone or not address: # Простая проверка, можно добавить валидацию формата + return jsonify({'status': 'error', 'message': 'Телефон и адрес обязательны'}), 400 + + success, message = update_user_profile(current_user_login, phone, address) + status_code = 200 if success else 400 + return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code + +# Эндпоинт для списания баллов (защищенный) - ВЫЗЫВАЕТСЯ ПРИ НАЖАТИИ "ПРИМЕНИТЬ" +@app.route('/redeem_points', methods=['POST']) +@token_required +def redeem_points(current_user_login): + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400 + + try: + points = int(data.get('points', 0)) # Приводим к int + except (ValueError, TypeError): + return jsonify({'status': 'error', 'message': 'Некорректное количество баллов'}), 400 + + if points <= 0: + return jsonify({'status': 'error', 'message': 'Количество баллов для списания должно быть положительным'}), 400 + + success, message, new_balance = redeem_points_from_user(current_user_login, points) + status_code = 200 if success else 400 # 400 может быть и из-за нехватки баллов + return jsonify({ + 'status': 'success' if success else 'error', + 'message': message, + 'new_balance': new_balance # Возвращаем новый баланс + }), status_code + +# Эндпоинт для начисления/ВОЗВРАТА баллов (кешбэк, отмена скидки) +@app.route('/earn_points', methods=['POST']) +@token_required +def earn_points(current_user_login): + data = request.get_json() + if not data: + return jsonify({'status': 'error', 'message': 'Ожидается JSON'}), 400 + + try: + # Ожидаем float для кешбэка, но int для возврата - обрабатываем оба + points_raw = data.get('points', 0) + points = float(points_raw) if '.' in str(points_raw) else int(points_raw) + except (ValueError, TypeError): + return jsonify({'status': 'error', 'message': 'Некорректное количество баллов'}), 400 + + if points <= 0: + # Возврат 0 баллов не имеет смысла, начисление 0 тоже. + return jsonify({'status': 'success', 'message': 'Баллы не изменены (сумма <= 0).'}), 200 + + # Используем универсальную функцию add_points_to_user, которая округляет до целых при начислении/возврате + success, message = add_points_to_user(current_user_login, points) + status_code = 200 if success else 500 # 500 если ошибка сохранения + # Возвращаем новый баланс после начисления/возврата + new_balance = 0 + if success: + user_data = get_user_profile_data(current_user_login) + if user_data: + new_balance = user_data.get('points', 0) + + return jsonify({ + 'status': 'success' if success else 'error', + 'message': message, + 'new_balance': new_balance # Возвращаем новый баланс + }), status_code + + +# Эндпоинт для обработки заказа (сохранение + начисление баллов) - ТЕПЕРЬ НЕ ИСПОЛЬЗУЕТСЯ КЛИЕНТОМ ДЛЯ СПИСАНИЯ +@app.route('/place_order', methods=['POST']) +@token_required +def place_order_route(current_user_login): + # Эта логика теперь разделена: + # 1. Клиент вызывает /redeem_points ПРИ НАЖАТИИ "Применить" (если нужно) + # 1.1 (Новое) Клиент может вызвать /earn_points для ОТМЕНЫ скидки (возврата баллов) + # 2. Клиент нажимает "Заказать (WhatsApp/QR)" + # 3. Клиент отправляет заказ в WhatsApp + # 4. Клиент вызывает /earn_points для НАЧИСЛЕНИЯ кешбэка (в фоне) + # 5. Клиент может (опционально) вызвать эндпоинт для сохранения заказа в историю + # Пока оставим этот эндпоинт заглушкой или для админки + return jsonify({'status': 'info', 'message': 'Этот эндпоинт больше не используется для оформления заказа клиентом.'}), 404 + + +@app.route('/order_history', methods=['GET']) +@token_required +def get_order_history_route(current_user_login): + history = get_order_history(current_user_login) + # Возвращает список заказов (от новых к старым) или пустой список + return jsonify(history) + + +# --- Эндпоинт стоп-листа (остается публичным для GET, POST требует защиты!) --- +@app.route('/stoplist', methods=['GET', 'POST']) +def stoplist_route(): # Переименовал, чтобы не конфликтовать с переменной stoplist + data = load_data() # Загружаем актуальные данные + products = data.get('products', []) + # stoplist_dict содержит актуальный стоп-лист {id_str: {'until': dt_utc, ...}} + stoplist_dict = data.get('stoplist', {}) + + if request.method == 'POST': + # !!!!! ВАЖНО: ДОБАВЬТЕ СЮДА ЗАЩИТУ !!!!! + # Например, проверку токена администратора + # admin_token_required(lambda: ...)() или if not is_admin(request): return 403 + # ПРИМЕР: Простейшая проверка секрета из заголовка или параметра + # admin_secret = os.getenv("ADMIN_SECRET", "change_this_secret") + # provided_secret = request.headers.get("X-Admin-Secret") or request.form.get("admin_secret") + # if not provided_secret or provided_secret != admin_secret: + # logging.warning("Попытка доступа к POST /stoplist без валидного секрета.") + # return jsonify({'status': 'error', 'message': 'Доступ запрещен'}), 403 + + + action = request.form.get('action') + product_id_str = request.form.get('product_id') # ID приходит как строка + + if not product_id_str or not product_id_str.isdigit(): + return jsonify({'status': 'error', 'message': 'Некорректный ID продукта'}), 400 + product_index = int(product_id_str) # Преобразуем в индекс + + # Проверяем, существует ли такой продукт + if not (0 <= product_index < len(products)): + return jsonify({'status': 'error', 'message': 'Продукт с таким ID не найден'}), 404 + + if action == 'add': + try: + minutes = int(request.form.get('minutes', 0)) + if minutes <= 0: + raise ValueError("Время должно быть положительным") + except (ValueError, TypeError): + return jsonify({'status': 'error', 'message': 'Некорректное время (требуется положительное число минут)'}), 400 + + # Время всегда считаем и храним в UTC + until_datetime_utc = datetime.now(timezone.utc) + timedelta(minutes=minutes) + stoplist_dict[product_id_str] = { # Обновляем словарь + 'until': until_datetime_utc, + 'minutes': minutes + } + data['stoplist'] = stoplist_dict # Обновляем данные для сохранения + save_data(data) # Сохраняем изменения + # Возвращаем время в ISO формате UTC + return jsonify({ + 'status': 'success', + 'message': f"Продукт {products[product_index]['name']} добавлен в стоп-лист на {minutes} минут.", + 'productId': product_id_str, + 'until': until_datetime_utc.isoformat(), + 'minutes': minutes + }), 200 + + elif action == 'remove': + if product_id_str in stoplist_dict: + del stoplist_dict[product_id_str] # Удаляем из словаря + data['stoplist'] = stoplist_dict # Обновляем данные для сохранения + save_data(data) # Сохраняем изменения + return jsonify({ + 'status': 'success', + 'message': f"Продукт {products[product_index]['name']} снят со стоп-листа.", + 'productId': product_id_str + }), 200 + else: + # Продукт и так не в стоп-листе (возможно, уже истек) + return jsonify({'status': 'success', 'message': 'Продукт не найден в активном стоп-листе.'}), 200 # Считаем успешным + else: + return jsonify({'status': 'error', 'message': 'Неизвестное действие'}), 400 + + # --- GET запрос (отображение страницы стоп-листа) --- + + # Форматируем стоп-лист для шаблона (ISO UTC) + stoplist_for_template_get = { + k: { + 'until': v['until'].isoformat(), # ISO UTC + 'minutes': v.get('minutes', 0) + } + for k, v in stoplist_dict.items() # Используем актуальный словарь + } + + # HTML для страницы стоп-листа + # ВАЖНО: Переменная `stoplist` переименована в `stoplist_page_data` в контексте Jinja, + # чтобы избежать конфликта с функцией `stoplist` в Python. + stoplist_html = ''' + + + + + + Стоп-лист + + + + +
+

Управление стоп-листом

+ {# TODO: Добавить форму для ввода пароля/секрета админа, если требуется защита #} + {# #} +
+ +
+
+ {# Проверяем, есть ли продукты #} + {% if products %} + {% for product in products %} +
+
{# Контейнер для названия и статуса #} +

{{ product['name'] }}

+
+ {# --- Содержимое будет заполнено/обновлено JS --- #} + Загрузка статуса... +
+
+
{# Контейнер для кнопки снятия стопа (управляется JS) #} + {# --- Содержимое будет заполнено/обновлено JS --- #} +
+
+ {% endfor %} + {% else %} +

Список продуктов пуст. Добавьте продукты в админ-панели.

+ {% endif %} +
+
+ + + + + ''' + return render_template_string(stoplist_html, products=products, stoplist_page_data=stoplist_for_template_get) + + +# --- Админка (ТРЕБУЕТ ЗАЩИТЫ!) --- +@app.route('/admin', methods=['GET', 'POST']) +def admin(): + # Временно отключаем проверку для тестирования + # TODO: Добавить механизм авторизации администратора позже + + # Пример простой защиты Basic Auth (раскомментируйте и настройте): + # from flask import Response # Раскомментировать импорт вверху + # admin_login = os.getenv("ADMIN_LOGIN", "admin") + # admin_pass = os.getenv("ADMIN_PASSWORD", "default_admin_pass_CHANGE_ME") + # auth = request.authorization + # # Используем хеширование для пароля админа! + # hash_pass = generate_password_hash(admin_pass) # Сгенерировать один раз + # logging.info(f"Admin hash: {hash_pass}") # Вывести хеш для сохранения + # stored_admin_hash = os.getenv("ADMIN_PASSWORD_HASH") # Хранить хеш, а не пароль + # if not auth or not stored_admin_hash or auth.username != admin_login or not check_password_hash(stored_admin_hash, auth.password): + # return Response( + # 'Требуется авторизация администратора', 401, + # {'WWW-Authenticate': 'Basic realm="Admin Area"'} + # ) + + data = load_data() + products = data.get('products', []) + categories = data.get('categories', []) + stoplist = data.get('stoplist', {}) # Словарь с datetime UTC {id_str: {'until': dt_utc,...}} + news_list = data.get('news', []) + qr_code_filename = data.get('qr_code') # Имя файла + + if request.method == 'POST': + action = request.form.get('action') + # --- Обработка категорий --- + if action == 'add_category': + category_name = request.form.get('category_name', '').strip() + if category_name and category_name not in categories: + categories.append(category_name) + save_data(data) + else: + logging.warning(f"Попытка добавить пустую или существующую категорию: '{category_name}'") + # Можно добавить flash-сообщение об ошибке + return redirect(url_for('admin')) + + elif action == 'delete_category': + try: + category_index = int(request.form.get('category_index')) + if 0 <= category_index < len(categories): + category_to_delete = categories.pop(category_index) + # Обновляем продукты с этой категорией + for product in products: + if product.get('category') == category_to_delete: + product['category'] = 'Без категории' # Устанавливаем дефолтную + save_data(data) + logging.info(f"Категория '{category_to_delete}' удалена.") + else: + logging.warning("Попытка удалить категорию с неверным индексом.") + except (ValueError, TypeError): + logging.error("Некорректный индекс категории для удаления.") + return redirect(url_for('admin')) + + elif action == 'move_category_up': + try: + category_index = int(request.form.get('category_index')) + if category_index > 0 and category_index < len(categories): + categories.insert(category_index - 1, categories.pop(category_index)) + save_data(data) + else: + logging.warning("Невозможно переместить категорию вверх (неверный индекс).") + except (ValueError, TypeError): + logging.error("Некорректный индекс категории для перемещения вверх.") + return redirect(url_for('admin')) + + elif action == 'move_category_down': + try: + category_index = int(request.form.get('category_index')) + if category_index >= 0 and category_index < len(categories) - 1: + categories.insert(category_index + 1, categories.pop(category_index)) + save_data(data) + else: + logging.warning("Невозможно переместить категорию вниз (неверный индекс).") + except (ValueError, TypeError): + logging.error("Некорректный индекс категории для перемещения вниз.") + return redirect(url_for('admin')) + + # --- Обработка продуктов --- + elif action == 'add': + try: + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '0').replace(',', '.') + price = float(price_str) + description = request.form.get('description', '').strip() + category = request.form.get('category') + photos_files = request.files.getlist('photos') + option_names = request.form.getlist('option_names') + option_prices = request.form.getlist('option_prices') + + if not name or price < 0: + logging.error("Имя и цена (неотрицательная) обязательны для добавления продукта.") + # TODO: Добавить flash-сообщение + return redirect(url_for('admin')) + + photos_list = [] + options_list = [] + + # Загрузка фото на HF + if photos_files and HF_TOKEN_WRITE: + api = HfApi() + uploads_dir = 'uploads' # Временная папка + os.makedirs(uploads_dir, exist_ok=True) + for photo in photos_files[:10]: # Ограничение на 10 фото + if photo and photo.filename: + # Используем только timestamp и расширение для имени файла + base, ext = os.path.splitext(secure_filename(photo.filename)) + photo_filename_hf = f"photo_{int(time.time() * 1000)}_{len(photos_list)}{ext}" # Уникальное имя + temp_path = os.path.join(uploads_dir, photo_filename_hf) # Сохраняем с уникальным именем + try: + photo.save(temp_path) + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=f"photos/{photo_filename_hf}", # Путь в репозитории + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Добавлено фото для блюда {name}" + ) + photos_list.append(photo_filename_hf) # Сохраняем имя файла HF + logging.info(f"Фото {photo_filename_hf} загружено на HF.") + except Exception as e: + logging.error(f"Ошибка загрузки фото {photo.filename} (-> {photo_filename_hf}) на HF: {e}") + finally: + # Удаляем временный файл + if os.path.exists(temp_path): + try: os.remove(temp_path) + except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_path}: {rm_err}") + elif photos_files and not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. Фотографии не будут загружены на HF.") + + + # Обработка опций + for opt_name, opt_price_str in zip(option_names, option_prices): + opt_name = opt_name.strip() + opt_price_str = opt_price_str.replace(',', '.') + if opt_name and opt_price_str: # Проверяем, что оба не пустые + try: + opt_price = float(opt_price_str) + if opt_price >= 0: + options_list.append({'name': opt_name, 'price': opt_price}) + else: + logging.warning(f"Цена опции '{opt_name}' должна быть неотрицательной. Пропуск.") + except ValueError: + logging.warning(f"Некорректная цена для опции '{opt_name}': {opt_price_str}. Пропуск.") + + # Находим следующий доступный ID (просто индекс в списке) + new_product_id = len(products) + + new_product = { + # 'id': new_product_id, # Явно ID не храним, используем индекс списка + 'name': name, + 'price': price, + 'description': description, + 'category': category if category in categories else 'Без категории', + 'photos': photos_list, + 'options': options_list + } + products.append(new_product) + save_data(data) + logging.info(f"Добавлен новый продукт: {name}") + except Exception as e: + logging.error(f"Ошибка при добавлении продукта: {e}", exc_info=True) + # TODO: Добавить flash-сообщение об ошибке + return redirect(url_for('admin')) + + elif action == 'edit': + try: + product_index = int(request.form.get('product_index')) + if not (0 <= product_index < len(products)): + logging.error("Неверный индекс продукта для редактирования.") + return redirect(url_for('admin')) + + product_to_edit = products[product_index] # Получаем ссылку на продукт + + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '0').replace(',', '.') + price = float(price_str) + description = request.form.get('description', '').strip() + category = request.form.get('category') + photos_files = request.files.getlist('photos') # Новые фото + existing_photos = request.form.getlist('existing_photos') # Старые фото, которые нужно оставить + option_names = request.form.getlist('option_names') + option_prices = request.form.getlist('option_prices') + + if not name or price < 0: + logging.error("Имя и цена (неотрицательная) обязательны для редактирования продукта.") + return redirect(url_for('admin')) + + photos_list = existing_photos # Начинаем со старых фото + options_list = [] + + # Загрузка новых фото на HF + if photos_files and HF_TOKEN_WRITE: + api = HfApi() + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + # Загружаем не больше (10 - кол-во оставшихся) + for photo in photos_files[:max(0, 10 - len(photos_list))]: + if photo and photo.filename: + base, ext = os.path.splitext(secure_filename(photo.filename)) + photo_filename_hf = f"photo_{int(time.time() * 1000)}_{len(photos_list)}{ext}" # Уникальное имя + temp_path = os.path.join(uploads_dir, photo_filename_hf) + try: + photo.save(temp_path) + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=f"photos/{photo_filename_hf}", + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Обновлено фото для блюда {name}" + ) + photos_list.append(photo_filename_hf) # Добавляем новое имя + logging.info(f"Новое фото {photo_filename_hf} загружено на HF при редактировании.") + except Exception as e: + logging.error(f"Ошибка загрузки нового фото {photo_filename_hf} на HF при редактировании: {e}") + finally: + if os.path.exists(temp_path): + try: os.remove(temp_path) + except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_path}: {rm_err}") + elif photos_files and not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. Новые фотографии не будут загружены на HF.") + + + # Обработка опций (пересоздаем список) + for opt_name, opt_price_str in zip(option_names, option_prices): + opt_name = opt_name.strip() + opt_price_str = opt_price_str.replace(',', '.') + if opt_name and opt_price_str: + try: + opt_price = float(opt_price_str) + if opt_price >= 0: + options_list.append({'name': opt_name, 'price': opt_price}) + else: + logging.warning(f"Цена опции '{opt_name}' должна быть неотрицательной при редактировании. Пропуск.") + except ValueError: + logging.warning(f"Некорректная цена для опции '{opt_name}' при редактировании: {opt_price_str}. Пропуск.") + + + # Обновляем данные продукта (используя .update() или прямым присваиванием ключей) + product_to_edit['name'] = name + product_to_edit['price'] = price + product_to_edit['description'] = description + product_to_edit['category'] = category if category in categories else 'Без категории' + product_to_edit['photos'] = photos_list + product_to_edit['options'] = options_list + + save_data(data) + logging.info(f"Продукт '{name}' (индекс {product_index}) обновлен.") + except Exception as e: + logging.error(f"Ошибка при редактировании продукта: {e}", exc_info=True) + return redirect(url_for('admin')) + + elif action == 'delete': + try: + product_index = int(request.form.get('product_index')) + if 0 <= product_index < len(products): + # Удаляем продукт + deleted_product = products.pop(product_index) + logging.info(f"Удален продукт: {deleted_product.get('name')} (бывший индекс {product_index})") + + # --- Корректировка стоп-листа --- + # Теперь ключи стоп-листа - это строки индексов. + # Нужно удалить запись для удаленного индекса и сдвинуть все последующие. + product_id_str_deleted = str(product_index) + new_stoplist_after_delete = {} + current_stoplist = data.get('stoplist', {}) # Берем текущий стоплист из загруженных данных + + for pid_str, stop_info in current_stoplist.items(): + try: + pid_int = int(pid_str) + if pid_int == product_index: + logging.info(f"Удалена запись стоп-листа для удаленного продукта {product_index}.") + continue # Пропускаем удаленный элемент + elif pid_int > product_index: + # Сдвигаем индекс для элементов после удаленного + new_index_str = str(pid_int - 1) + new_stoplist_after_delete[new_index_str] = stop_info + logging.debug(f"Сдвинут индекс стоп-листа: {pid_str} -> {new_index_str}") + else: # pid_int < product_index + # Оставляем как есть для элементов до удаленного + new_stoplist_after_delete[pid_str] = stop_info + except ValueError: + logging.warning(f"Некорректный ID (не число) '{pid_str}' в стоп-листе при удалении продукта. Запись пропущена.") + + data['stoplist'] = new_stoplist_after_delete # Обновляем стоплист в данных + save_data(data) # Сохраняем все изменения (продукты и стоплист) + else: + logging.warning("Попытка удалить продукт с неверным индексом.") + except (ValueError, TypeError): + logging.error("Некорректный индекс продукта для удаления.") + return redirect(url_for('admin')) + + # --- Обработка QR-кода --- + elif action == 'upload_qr': + qr_file = request.files.get('qr_file') + if qr_file and qr_file.filename and HF_TOKEN_WRITE: + try: + base, ext = os.path.splitext(secure_filename(qr_file.filename)) + # Используем фиксированное или уникальное имя для QR + qr_filename_hf = f"payment_qr_{int(time.time())}{ext}" + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + temp_path = os.path.join(uploads_dir, qr_filename_hf) + qr_file.save(temp_path) + api = HfApi() + # Загружаем в корень датасета + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=qr_filename_hf, # Имя файла в репозитории + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message="Обновлен QR-код для оплаты" + ) + data['qr_code'] = qr_filename_hf # Сохраняем новое имя файла + save_data(data) + logging.info(f"QR-код {qr_filename_hf} успешно загружен.") + except Exception as e: + logging.error(f"Ошибка загрузки QR-кода на HF: {e}", exc_info=True) + finally: + # Удаляем временный файл + if 'temp_path' in locals() and os.path.exists(temp_path): + try: os.remove(temp_path) + except OSError as rm_err: logging.error(f"Не удалось удалить временный QR файл {temp_path}: {rm_err}") + elif qr_file and not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. QR-код не будет загружен на HF.") + else: + logging.warning("Файл QR-кода не был предоставлен или имеет некорректное имя.") + return redirect(url_for('admin')) + + + # --- Обработка новостей --- + elif action == 'add_news': + try: + news_title = request.form.get('news_title', '').strip() + news_text = request.form.get('news_text', '').strip() # Может содержать HTML + news_photo_file = request.files.get('news_photo') + # Получаем значения времени, обрабатываем пустые строки как 0 + expiry_days_str = request.form.get('expiry_days') or '0' + expiry_hours_str = request.form.get('expiry_hours') or '0' + expiry_minutes_str = request.form.get('expiry_minutes') or '0' + + expiry_days = int(expiry_days_str) + expiry_hours = int(expiry_hours_str) + expiry_minutes = int(expiry_minutes_str) + + + if not news_title or not news_text: + logging.error("Заголовок и текст новости обязательны.") + return redirect(url_for('admin')) + + news_photo_filename_hf = None + # Загрузка фото новости на HF + if news_photo_file and news_photo_file.filename and HF_TOKEN_WRITE: + try: + base, ext = os.path.splitext(secure_filename(news_photo_file.filename)) + news_photo_filename_hf = f"news_{int(time.time() * 1000)}{ext}" # Уникальное имя + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + temp_path = os.path.join(uploads_dir, news_photo_filename_hf) + news_photo_file.save(temp_path) + api = HfApi() + api.upload_file( + path_or_fileobj=temp_path, + path_in_repo=f"photos/{news_photo_filename_hf}", # Сохраняем в папку photos + repo_id=REPO_ID, + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Добавлено фото для новости: {news_title}" + ) + logging.info(f"Фото новости {news_photo_filename_hf} загружено.") + except Exception as e: + logging.error(f"Ошибка загрузки фото новости на HF: {e}") + news_photo_filename_hf = None # Сбрасываем имя файла, если загрузка не удалась + finally: + if 'temp_path' in locals() and os.path.exists(temp_path): + try: os.remove(temp_path) + except OSError as rm_err: logging.error(f"Не удалось удалить временный файл новости {temp_path}: {rm_err}") + elif news_photo_file and not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN (write) не установлен. Фото новости не будет загружено на HF.") + + + # Расчет времени истечения в UTC ISO формате + expiry_time_iso = None + total_delta_seconds = expiry_days * 86400 + expiry_hours * 3600 + expiry_minutes * 60 + if total_delta_seconds > 0: + expiry_datetime_utc = datetime.now(timezone.utc) + timedelta(seconds=total_delta_seconds) + expiry_time_iso = expiry_datetime_utc.isoformat() # Сохраняем ISO UTC строку + + new_news_item = { + 'title': news_title, + 'text': news_text, + 'photo': news_photo_filename_hf, # null если не загружено + 'expiry': expiry_time_iso # null если не установлено + } + if 'news' not in data or not isinstance(data['news'], list): + data['news'] = [] # Инициализация, если нужно + data['news'].append(new_news_item) + save_data(data) + logging.info(f"Добавлена новость: {news_title}") + except ValueError: + logging.error("Некорректное значение времени для срока действия новости.") + except Exception as e: + logging.error(f"Ошибка при добавлении новости: {e}", exc_info=True) + return redirect(url_for('admin')) + + elif action == 'delete_news': + try: + news_index = int(request.form.get('news_index')) + # Важно: Ищем индекс в исходном списке news_list, а не в отсортированном + if 'news' in data and isinstance(data['news'], list) and 0 <= news_index < len(data['news']): + deleted_news = data['news'].pop(news_index) + logging.info(f"Новость '{deleted_news.get('title')}' удалена.") + save_data(data) + else: + logging.warning("Попытка удалить новость с неверным индексом.") + except (ValueError, TypeError): + logging.error("Некорректный индекс новости для удаления.") + return redirect(url_for('admin')) + + + # --- Отображение админки (GET запрос) --- + # Генерируем полный URL для QR-кода + qr_code_admin_url = None + if qr_code_filename: + qr_code_admin_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{qr_code_filename}" + + # Сортируем новости для админки (как на главной, но без фильтрации) + def get_expiry_dt_admin(item): + exp_str = item.get('expiry') + if exp_str: + try: return datetime.fromisoformat(exp_str.replace('Z', '+00:00')).astimezone(timezone.utc) + except: return datetime.max.replace(tzinfo=timezone.utc) # Ошибки в конец + return datetime.max.replace(tzinfo=timezone.utc) # Без даты в конец + # Создаем копию списка новостей с добавлением оригинального индекса перед сортировкой + news_list_with_indices = [{'index': i, **item} for i, item in enumerate(news_list)] + sorted_news_list_admin = sorted(news_list_with_indices, key=get_expiry_dt_admin, reverse=True) + + + # ВАЖНО: Переменная `stoplist` переименована в `stoplist_admin_data` в контексте Jinja, + # чтобы избежать конфликта с функцией `stoplist` в Python. + admin_html = ''' + + + + + + Админ-панель + + + + + +
+

Админ-панель

+ {# Ссылка на стоп-лист #} +
+ Управление стоп-листом + Открыть меню +
+ + +
+

Управление категориями

+
+ + + + +
+
+ {% if categories %} + {% for category in categories %} +
+ {{ category }} +
+ {# Перемещение вверх #} + {% if not loop.first %} +
+ + + +
+ {% else %} + {# Пустышка для выравнивания #} + {% endif %} + {# Перемещение вниз #} + {% if not loop.last %} +
+ + + +
+ {% else %} + {# Пустышка для выравнивания #} + {% endif %} + {# Удаление #} +
+ + + +
+
+
+ {% endfor %} + {% else %} +

Категорий пока нет.

+ {% endif %} +
+
+ + +
+

Добавить блюдо

+
+ + + + + + + + + + + + + {# --- Опции --- #} +
+ + {# Сюда JS добавит поля #} +
+ + + +
+
+ + +
+

Список блюд ({{ products|length }})

+
+ +
+
+ {% if products %} + {% for product in products %} +
+ {# Фото продукта #} +
{# Контейнер для фото #} + {% if product.get('photos') and product['photos']|length > 0 %} + {{ product['name'] }} + {% else %} +
Нет фото
+ {% endif %} +
+ + {# Основная информация о продукте #} +
+ {{ product['name'] }} + {{ product['price'] }} с + Категория: {{ product.get('category', 'Без категории') }} + {% if product.get('description') %} + Описание:
{{ product['description'] | safe }}
{# Обрезаем длинное описание #} + {% endif %} + {% if product.get('options') %} + Опции: {{ product['options']|map(attribute='name')|join(', ') if product['options'] else 'Нет' }} + {% endif %} + {% if product.get('photos') %} + Фото: {{ product['photos']|length }} шт. + {% endif %} +
+ + {# Кнопки действий #} +
+ +
+ + + +
+
+
+ {% endfor %} + {% else %} +

Блюд пока нет.

+ {% endif %} +
+
+ + +
+

Управление новостями

+
+ + + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+

Текущие новости

+ {% if sorted_news_list_admin %} + {% for news_item_indexed in sorted_news_list_admin %} + {% set news_item = news_item_indexed %} {# Убираем вложенность для удобства #} +
+ {# Фото новости #} +
+ {% if news_item.get('photo') %} + Фото новости + {% endif %} +
+ {# Текст новости #} +
+ {{ news_item.get('title', 'Без заголовка') }} + {{ news_item.get('text', '') | safe }} + {% if news_item.get('expiry') %} + Актуально до (UTC): {{ news_item.expiry[:16].replace('T', ' ') }} + {% else %} + Действует бессрочно + {% endif %} +
+ {# Кнопка удаления #} +
+
+ + {# Используем оригинальный индекс #} + + +
+
+
+ {% endfor %} + {% else %} +

Новостей пока нет.

+ {% endif %} +
+
+ + + +
+

QR-код для оплаты

+
+ + + + +
+
+ {% if qr_code_admin_url %} +

Текущий QR-код:

+ {# Добавляем кэш-бастер к URL #} + Текущий QR-код +

Имя файла: {{ qr_code_filename }}

+ {% else %} +

QR-код еще не загружен.

+ {% endif %} +
+
+
+ + + + + + + + ''' + # Передаем данные в шаблон админки + return render_template_string( + admin_html, + products=products, + categories=categories, + repo_id=REPO_ID, + qr_code_filename=qr_code_filename, # Имя файла + qr_code_admin_url=qr_code_admin_url, # Полный URL + news_list=news_list, # Несортированный список для индексов удаления + sorted_news_list_admin=sorted_news_list_admin, # Отсортированный для отображения + stoplist_admin_data=stoplist # Передаем стоп-лист для информации, если нужно (под именем stoplist_admin_data) + ) + + +# --- QR Меню (публичное, без JWT) --- +@app.route('/qrmenu') +def qrmenu(): + # Логика загрузки данных и обработки стоп-листа и новостей (как в '/') + data = load_data() + products = data.get('products', []) + categories = data.get('categories', []) + stoplist_raw = data.get('stoplist', {}) # Словарь {id_str: {'until': dt_utc,...}} + category_counts = get_category_counts(products) + news_list = data.get('news', []) + # qr_code не нужен в QR меню + + # Обработка стоп-листа для QR меню + current_time_utc = datetime.now(timezone.utc) + active_stoplist_qr = {} + # Нет необходимости сохранять изменения из QR меню + for product_id, stop_info in stoplist_raw.items(): + if isinstance(stop_info.get('until'), datetime): + if stop_info['until'] > current_time_utc: + active_stoplist_qr[product_id] = stop_info + else: + logging.warning(f"Некорректная запись stoplist для ID {product_id} при запросе /qrmenu: {stop_info}") + + stoplist_for_template_qr = { + k: { + 'until': v['until'].isoformat(), # ISO UTC + 'minutes': v.get('minutes', 0) + } + for k, v in active_stoplist_qr.items() + } + + # Обработка новостей для QR меню (как в '/') + now_utc_aware_qr = datetime.now(timezone.utc) + news_for_template_qr = sorted( + news_list, + key=lambda item: get_expiry_dt_admin(item), # Используем ту же функцию сортировки + reverse=True + ) + news_for_template_qr = [item for item in news_for_template_qr if not item.get('expiry') or get_expiry_dt_admin(item) > now_utc_aware_qr] + + + # HTML для QR меню (упрощенный) + # ВАЖНО: Переменная `stoplist_qr` переименована в `stoplist_qr_data` в контексте Jinja, + # чтобы избежать конфликта с функцией `stoplist` в Python. + qrmenu_html = ''' + + + + + + Чайхана Emir QR menu + + + + + + +
+
+ +

Чайхана "Emir"

+

Встречаем с улыбкой, готовим с любовью!

+

Время готовки: 15-30 мин

+ +
+
+ + {% for category in categories %} + + {% endfor %} + {# Кнопка "Все категории" не нужна здесь, т.к. нет нижней панели #} +
+
+ +
+
+ {% for product in products %} +
+ {% if product.get('photos') and product['photos']|length > 0 %} +
+ {{ product['name'] }} +
+ {% else %} +
+ Фото нет +
+ {% endif %} +

{{ product['name'] }}

+
{{ product['price'] }} с
+

{{ product['description'] | safe }}

{# Отображаем полное описание #} +
+ {# Отображение стоп-листа #} + {% set product_id_str = loop.index0|string %} + {% if stoplist_qr.get(product_id_str) %} + {# Можно добавить таймер, если нужно #} +

Временно недоступно (Стоп-лист)

+ {#

Будет готово через:

#} + {% endif %} +
+
+ {% endfor %} +
+ {# Кнопка новостей #} +
+ +
+ + +
+ + {# Модальное окно деталей продукта #} + + + {# Модальное окно новостей #} + + + + {# #} {# Popper не нужен #} + + + + +''' + return render_template_string( + qrmenu_html, + products=products, + categories=categories, + category_counts=category_counts, + stoplist_qr=stoplist_for_template_qr, # Передаем стоплист для QR + repo_id=REPO_ID, + news_for_template_qr=news_for_template_qr, # Новости для QR + logo_url=LOGO_URL + ) + + +# --- Запуск приложения --- +if __name__ == '__main__': + # Запуск периодического бэкапа в отдельном потоке + # Убедитесь, что HF_TOKEN_WRITE установлен как переменная окружения для write доступа + if HF_TOKEN_WRITE: + backup_thread = threading.Thread(target=periodic_backup, daemon=True) + backup_thread.start() + else: + logging.warning("HF_TOKEN (write) не установлен. Периодический бэкап на Hugging Face отключен.") + + # Запуск Flask приложения + # Используйте Gunicorn или другой WSGI сервер для продакшена вместо app.run(debug=True) + # Пример для локального запуска: + port = int(os.environ.get("PORT", 7860)) # Получаем порт из окружения или используем 7860 + logging.info(f"Запуск Flask приложения на порту {port}") + # debug=True перезагружает приложение при изменениях кода и дает подробные ошибки, удобно для разработки + # debug=False обязательно для продакшена! host='0.0.0.0' делает приложение доступным извне контейнера/сети + app.run(debug=False, host='0.0.0.0', port=port) \ No newline at end of file