# -*- 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 %}