# -*- 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 from functools import wraps import math app = Flask(__name__) 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" 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 logging.basicConfig(level=logging.INFO) 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'] = [] for product in data.get('products', []): product.setdefault('has_container', False) product.setdefault('container_price', 0) current_time_utc = datetime.now(timezone.utc) updated_news = [] for news_item in data.get('news', []): expiry_datetime_utc = None if isinstance(news_item, dict) and 'expiry' in news_item and news_item['expiry']: try: dt_str = news_item['expiry'] dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00')) if dt.tzinfo is None: 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: updated_news.append(news_item) data['news'] = updated_news stoplist_processed = {} current_time_utc_check = datetime.now(timezone.utc) for product_id, stop_info in data.get('stoplist', {}).items(): if isinstance(stop_info, dict) and 'until' in stop_info: try: until_dt_iso = stop_info['until'] until_dt = datetime.fromisoformat(until_dt_iso.replace('Z', '+00:00')) if until_dt.tzinfo is None: until_dt = until_dt.replace(tzinfo=timezone.utc) else: until_dt = until_dt.astimezone(timezone.utc) if until_dt > current_time_utc_check: stoplist_processed[str(product_id)] = { 'until': until_dt, '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) return {'products': [], 'categories': [], 'stoplist': {}, 'qr_code': None, 'news': []} def save_data(data): try: data_to_save = data.copy() data_to_save['stoplist'] = { pid: { 'until': info['until'].isoformat(), 'minutes': info.get('minutes', 0) } for pid, info in data.get('stoplist', {}).items() if isinstance(info.get('until'), datetime) } news_list = data.get('news', []) if not isinstance(news_list, list): logging.warning("Ключ 'news' не является списком при сохранении. Сброс на пустой список.") news_list = [] valid_news = [] for item in news_list: if isinstance(item, dict): 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}") 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}.") except RepositoryNotFoundError as e: logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face (для файла {DATA_FILE}): {e}") raise 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': []} if not isinstance(user_data.get('users'), list): logging.warning(f"Ключ 'users' в {USER_DATA_FILE} не является списком. Сброс к дефолтной.") return {'users': []} for user in user_data['users']: if 'points' not in user: user['points'] = 0 if 'order_history' not in user: user['order_history'] = [] 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}") 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}.") 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_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 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}") 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 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'), 'address': user.get('address'), 'points': user.get('points', 0), } 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) if points_to_add <= 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) 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) 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) 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: 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') order_timestamp_utc = datetime.now(timezone.utc).isoformat() order_timestamp_local = datetime.now(bishkek_tz).strftime('%Y-%m-%d %H:%M:%S %Z%z') 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 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:] 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', []) try: 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 [] def create_access_token(identity): try: payload = { 'exp': datetime.now(timezone.utc) + timedelta(days=30), 'iat': datetime.now(timezone.utc), 'sub': identity } 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): @wraps(f) def decorated(*args, **kwargs): token = None 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 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}' из токена не найден в базе данных.") 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 @app.route('/') def menu(): data = load_data() products = data.get('products', []) categories = data.get('categories', []) stoplist_raw = data.get('stoplist', {}) category_counts = get_category_counts(products) news_list = data.get('news', []) qr_code_filename = data.get('qr_code') current_time_utc = datetime.now(timezone.utc) active_stoplist = {} needs_save = False 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[product_id] = stop_info else: needs_save = True logging.info(f"Запись стоп-листа для продукта {product_id} истекла и не будет передана в шаблон.") else: logging.warning(f"Некорректная запись (не datetime) в stoplist_raw для ID {product_id}: {stop_info}") if needs_save: data['stoplist'] = active_stoplist save_data(data) stoplist_for_template = { k: { 'until': v['until'].isoformat(), '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: dt = datetime.fromisoformat(expiry_str.replace('Z', '+00:00')) 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), reverse=True ) news_for_template = [item for item in news_for_template if not get_expiry_datetime(item) or get_expiry_datetime(item) > now_utc_aware] qr_code_url = None if qr_code_filename: qr_code_url = f"https://huggingface.co/datasets/{REPO_ID}/resolve/main/{qr_code_filename}" menu_html = ''' Ресторан Премиум

Чайхана "Emir"

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

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

{% for category in categories %} {% endfor %}
{% for product in products %}
{% if product.get('photos') and product['photos']|length > 0 %}
{{ product['name'] }}
{% endif %}

{{ product['name'] }}

{{ product['price'] }} с

{{ product['description'] }}

{% endfor %}
''' return render_template_string( menu_html, products=products, categories=categories, category_counts=category_counts, stoplist_data=stoplist_for_template, repo_id=REPO_ID, qr_code_url=qr_code_url, news_for_template=news_for_template, logo_url=LOGO_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: return jsonify({'status': 'error', 'message': 'Блюдо не найдено'}), 404 detail_html = '''

{{ product['name'] }}

{% if product.get('photos') and product['photos']|length > 0 %} {% for photo in product['photos'] %}
{{ product['name'] }}
{% endfor %} {% else %}
Фото отсутствует
{% endif %}
{% if product.get('photos') and product['photos']|length > 1 %}
{% endif %}

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

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

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

{% if product.get('options') and product['options']|length > 0 %}

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

{% endif %} {% if product.has_container and product.container_price > 0 %}

* К этому блюду будет добавлен контейнер (+{{ product.container_price }} c)

{% endif %}
''' return render_template_string(detail_html, product=product, repo_id=REPO_ID) @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') if not login or not password: return jsonify({'status': 'error', 'message': 'Логин и пароль обязательны'}), 400 success, message = register_user(login, password) status_code = 201 if success else 400 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 @app.route('/profile', methods=['GET']) @token_required def get_profile(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)) 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 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: 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: return jsonify({'status': 'success', 'message': 'Баллы не изменены (сумма <= 0).'}), 200 success, message = add_points_to_user(current_user_login, points) status_code = 200 if success else 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): 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) @app.route('/stoplist', methods=['GET', 'POST']) def stoplist_route(): data = load_data() products = data.get('products', []) stoplist_dict = data.get('stoplist', {}) if request.method == 'POST': action = request.form.get('action') product_id_str = request.form.get('product_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 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) 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 stoplist_for_template_get = { k: { 'until': v['until'].isoformat(), 'minutes': v.get('minutes', 0) } for k, v in stoplist_dict.items() } stoplist_html = ''' Стоп-лист

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

{% if products %} {% for product in products %}

{{ product['name'] }}

Загрузка статуса...
{% 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(): data = load_data() products = data.get('products', []) categories = data.get('categories', []) stoplist = data.get('stoplist', {}) 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}'") 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') has_container = request.form.get('has_container') == 'on' container_price_str = request.form.get('container_price', '0').replace(',', '.') container_price = float(container_price_str) if has_container else 0 if not name or price < 0 or (has_container and container_price < 0): logging.error("Имя, цена (>=0) и цена контейнера (>=0, если выбран) обязательны.") return redirect(url_for('admin')) photos_list = [] options_list = [] 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]: 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} (-> {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}. Пропуск.") new_product_id = len(products) new_product = { 'name': name, 'price': price, 'description': description, 'category': category if category in categories else 'Без категории', 'photos': photos_list, 'options': options_list, 'has_container': has_container, 'container_price': container_price if has_container else 0 } products.append(new_product) save_data(data) logging.info(f"Добавлен новый продукт: {name}") except Exception as e: logging.error(f"Ошибка при добавлении продукта: {e}", exc_info=True) 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') has_container = request.form.get('has_container') == 'on' container_price_str = request.form.get('container_price', '0').replace(',', '.') container_price = float(container_price_str) if has_container else 0 if not name or price < 0 or (has_container and container_price < 0): logging.error("Имя, цена (>=0) и цена контейнера (>=0, если выбран) обязательны для редактирования.") return redirect(url_for('admin')) photos_list = existing_photos options_list = [] if photos_files and HF_TOKEN_WRITE: api = HfApi() uploads_dir = 'uploads' os.makedirs(uploads_dir, exist_ok=True) 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}. Пропуск.") 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 product_to_edit['has_container'] = has_container product_to_edit['container_price'] = container_price if has_container else 0 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: 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')) 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_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() news_photo_file = request.files.get('news_photo') 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 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}", 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.") 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() new_news_item = { 'title': news_title, 'text': news_text, 'photo': news_photo_filename_hf, 'expiry': expiry_time_iso } 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')) 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')) 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) admin_html = ''' Админ-панель

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

Управление стоп-листом Открыть меню

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

{% if categories %} {% for category in categories %}
{{ category }}
{% if not loop.first %}
{% else %} {% endif %} {% if not loop.last %}
{% else %} {% endif %}
{% endfor %} {% else %}

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

{% endif %}

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

Список блюд ({{ 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'] }} с {% if product.has_container and product.container_price > 0 %} (+{{ product.container_price }}с конт.) {% endif %} Категория: {{ 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-код:

Текущий 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, news_list=news_list, sorted_news_list_admin=sorted_news_list_admin, stoplist_admin_data=stoplist ) @app.route('/qrmenu') def qrmenu(): data = load_data() products = data.get('products', []) categories = data.get('categories', []) stoplist_raw = data.get('stoplist', {}) category_counts = get_category_counts(products) news_list = data.get('news', []) current_time_utc = datetime.now(timezone.utc) active_stoplist_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(), 'minutes': v.get('minutes', 0) } for k, v in active_stoplist_qr.items() } 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] 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'] }} с {% if product.has_container and product.container_price > 0 %} (+{{ product.container_price }}с конт.) {% endif %}

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

{% set product_id_str = loop.index0|string %} {% if stoplist_qr.get(product_id_str) %}

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

{% endif %}
{% endfor %}
''' return render_template_string( qrmenu_html, products=products, categories=categories, category_counts=category_counts, stoplist_qr=stoplist_for_template_qr, repo_id=REPO_ID, news_for_template_qr=news_for_template_qr, logo_url=LOGO_URL ) if __name__ == '__main__': if HF_TOKEN_WRITE: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() else: logging.warning("HF_TOKEN (write) не установлен. Периодический бэкап на Hugging Face отключен.") port = int(os.environ.get("PORT", 7860)) logging.info(f"Запуск Flask приложения на порту {port}") app.run(debug=False, host='0.0.0.0', port=port)