diff --git "a/app.py" "b/app.py" deleted file mode 100644--- "a/app.py" +++ /dev/null @@ -1,6157 +0,0 @@ - -# -*- 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. - # ----- ИСПРАВЛЕНИЕ N1 НАЧАЛО: Добавлена кнопка для вызова openEditModal в HTML ----- - 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) - ) - -# ----- ИСПРАВЛЕНИЕ N1 КОНЕЦ: Добавлена кнопка для вызова openEditModal в HTML ----- - - -# --- 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