from flask import Flask, render_template_string, request, redirect, url_for, jsonify, session import json import os import logging import threading import time from datetime import datetime, timedelta, timezone import pytz # Keep pytz if needed elsewhere, but timezone is now preferred for basic UTC handling 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 app = Flask(__name__) # SECURITY: Ensure this key is strong and loaded from environment in production app.secret_key = os.getenv("FLASK_SECRET_KEY", "a_much_stronger_default_secret_key_please_change_v3") # Changed secret key significantly app.config['SESSION_COOKIE_SAMESITE'] = 'None' app.config['SESSION_COOKIE_SECURE'] = True # Keep secure cookies # Set session lifetime (e.g., 30 days) app.permanent_session_lifetime = timedelta(days=30) DATA_FILE = 'data_ultra_flowers.json' # Changed filename USER_DATA_FILE = 'data_ultra_flowerusers.json' # Changed filename REPO_ID = "Kgshop/Testbasebase" # <<< UPDATED REPOSITORY ID HF_TOKEN_WRITE = os.getenv("HF_TOKEN") HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Use the same token if read/write access is the same # Placeholder for a new logo if available, otherwise keep the old one or remove LOGO_URL = "https://huggingface.co/spaces/shopflo0/flowerbase/resolve/main/Gemini_Generated_Image_ebfuonebfuonebfu.jpg" # Placeholder logo logging.basicConfig(level=logging.INFO) # Changed level to INFO for less verbosity in production # --- Helper Function & Jinja Filter --- def format_iso_datetime_filter(iso_str): """ Parses an ISO 8601 datetime string (handling 'Z' for UTC) and returns a timezone-aware datetime object (UTC). Returns None if parsing fails. """ if not iso_str: return None try: # Check if it's already a datetime object (e.g., from previous processing) if isinstance(iso_str, datetime): dt = iso_str # Ensure it's timezone-aware (assume UTC if naive) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt else: # Process string format expiry_dt_str = str(iso_str) # Ensure it's a string if 'Z' in expiry_dt_str: expiry_dt_str = expiry_dt_str.replace('Z', '+00:00') # Handle potential missing timezone info by assuming UTC dt = datetime.fromisoformat(expiry_dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt except (ValueError, TypeError) as e: logging.warning(f"Could not parse date string: {iso_str}. Error: {e}") return None # Register the function as a Jinja2 filter app.template_filter('format_iso_datetime')(format_iso_datetime_filter) # --- Data Loading/Saving Functions --- def load_data(): try: # Attempt to download only if tokens are set, otherwise use local if HF_TOKEN_READ: try: download_db_from_hf() except RepositoryNotFoundError: logging.warning(f"Репозиторий HF {REPO_ID} не найден. Попытка использовать локальный файл {DATA_FILE}.") except Exception as download_err: logging.error(f"Ошибка загрузки {DATA_FILE} с HF: {download_err}. Попытка использовать локальный файл.") if not os.path.exists(DATA_FILE): logging.warning(f"Локальный файл базы данных '{DATA_FILE}' не найден. Создается пустая база данных.") return {'products': [], 'categories': [], 'news': []} with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) logging.info(f"Данные магазина цветов успешно загружены из '{DATA_FILE}'") if not isinstance(data, dict): logging.warning("Структура JSON некорректна, создается структура по умолчанию.") return {'products': [], 'categories': [], 'news': []} # Ensure essential keys exist data.setdefault('products', []) data.setdefault('categories', []) data.setdefault('news', []) # News expiry check current_time_utc = datetime.now(timezone.utc) updated_news = [] news_list = data.get('news', []) if not isinstance(news_list, list): # Handle case where 'news' might not be a list logging.warning("Ключ 'news' в JSON не является списком. Новости будут проигнорированы.") news_list = [] for news_item in news_list: if not isinstance(news_item, dict): # Skip non-dict items logging.warning(f"Найден несловарный элемент в списке новостей: {news_item}") continue expiry_str = news_item.get('expiry') if expiry_str: expiry_datetime = format_iso_datetime_filter(expiry_str) # Use the filter function for parsing if expiry_datetime: # Compare timezone-aware datetimes if expiry_datetime > current_time_utc: updated_news.append(news_item) else: logging.info(f"Новость '{news_item.get('title', 'N/A')}' истекла и удалена.") else: # Keep news if date format is wrong, but log error logging.error(f"Ошибка парсинга даты истечения для новости '{news_item.get('title', 'N/A')}'. Новость сохранена.") updated_news.append(news_item) else: updated_news.append(news_item) # Keep news without expiry data['news'] = updated_news return data except FileNotFoundError: # This case is now handled by the check after download attempt logging.warning(f"Локальный файл базы данных '{DATA_FILE}' не найден и не удалось скачать. Создается пустая база данных.") return {'products': [], 'categories': [], 'news': []} except json.JSONDecodeError: logging.error(f"Ошибка: Невозможно декодировать JSON-файл '{DATA_FILE}'. Создается пустая база данных.") return {'products': [], 'categories': [], 'news': []} except Exception as e: logging.error(f"Неожиданная ошибка при загрузке данных магазина цветов: {e}", exc_info=True) return {'products': [], 'categories': [], 'news': []} def save_data(data): try: data_to_save = data.copy() # Ensure datetime objects are converted to ISO format strings before saving if 'news' in data_to_save and isinstance(data_to_save['news'], list): for news_item in data_to_save['news']: if not isinstance(news_item, dict): continue # Skip non-dict items # Ensure timestamp and expiry are strings if 'timestamp' in news_item and isinstance(news_item['timestamp'], datetime): # Ensure UTC before formatting ts_dt = news_item['timestamp'] if ts_dt.tzinfo is None: ts_dt = ts_dt.replace(tzinfo=timezone.utc) else: ts_dt = ts_dt.astimezone(timezone.utc) news_item['timestamp'] = ts_dt.isoformat() if 'expiry' in news_item and isinstance(news_item['expiry'], datetime): exp_dt = news_item['expiry'] if exp_dt.tzinfo is None: exp_dt = exp_dt.replace(tzinfo=timezone.utc) else: exp_dt = exp_dt.astimezone(timezone.utc) news_item['expiry'] = exp_dt.isoformat() 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}'") # Attempt to upload only if write token is set if HF_TOKEN_WRITE: upload_db_to_hf() except Exception as e: logging.error(f"Ошибка при сохранении данных магазина цветов: {e}", exc_info=True) # Decide if you want to raise the exception or just log it # raise def upload_db_to_hf(): if not HF_TOKEN_WRITE: logging.info("HF_TOKEN_WRITE не установлен, загрузка на Hugging Face пропущена.") return if not os.path.exists(DATA_FILE): logging.warning(f"Файл {DATA_FILE} не найден для загрузки на HF.") 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"Резервная копия JSON-базы данных цветов загружена в {REPO_ID}.") except Exception as e: logging.error(f"Ошибка при загрузке резервной копии базы данных цветов {DATA_FILE}: {e}") def download_db_from_hf(): if not HF_TOKEN_READ: logging.info("HF_TOKEN_READ не установлен, загрузка с Hugging Face пропущена.") return # Don't raise error, just skip download attempt try: logging.info(f"Попытка загрузки {DATA_FILE} из {REPO_ID}...") hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", force_filename=DATA_FILE, # Ensure it overwrites with the correct name local_dir_use_symlinks=False # Recommended for compatibility ) logging.info(f"JSON-база данных цветов '{DATA_FILE}' успешно загружена из {REPO_ID}.") except RepositoryNotFoundError as e: logging.warning(f"Репозиторий {REPO_ID} не найден на Hugging Face: {e}.") raise # Re-raise to be caught in load_data except Exception as e: # Log other potential download errors (network issues, permissions, file not found on remote) logging.error(f"Ошибка при загрузке JSON-базы данных цветов '{DATA_FILE}' с Hugging Face: {e}") # Check if it's specifically a file not found error (often HTTP 404) if "404" in str(e): logging.warning(f"Файл {DATA_FILE} не найден в репозитории {REPO_ID}.") raise # Re-raise to be caught in load_data # --- User Data Functions (Similar adjustments) --- def load_user_data(): try: if HF_TOKEN_READ: try: download_user_db_from_hf() except RepositoryNotFoundError: logging.warning(f"Репозиторий HF {REPO_ID} для пользователей не найден. Попытка использовать локальный файл {USER_DATA_FILE}.") except Exception as download_err: logging.error(f"Ошибка загрузки {USER_DATA_FILE} с HF: {download_err}. Попытка использовать локальный файл.") 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(f"Данные пользователей успешно загружены из '{USER_DATA_FILE}'") if not isinstance(user_data, dict): logging.warning("Структура JSON пользователей некорректна, создается структура по умолчанию.") return {'users': []} user_data.setdefault('users', []) # Add basic validation for users list if needed if not isinstance(user_data['users'], list): logging.warning("Ключ 'users' в JSON пользователей не является списком. Создается пустой список.") user_data['users'] = [] return user_data except FileNotFoundError: # Handled by os.path.exists check now 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: # Ensure datetime objects in order history are strings (in UTC ISO format) for user in user_data.get('users', []): if 'order_history' in user and isinstance(user['order_history'], list): for order in user['order_history']: if not isinstance(order, dict): continue # Skip non-dict items if 'timestamp' in order and isinstance(order['timestamp'], datetime): ts_dt = order['timestamp'] if ts_dt.tzinfo is None: ts_dt = ts_dt.replace(tzinfo=timezone.utc) else: ts_dt = ts_dt.astimezone(timezone.utc) order['timestamp'] = ts_dt.isoformat() 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}'") if HF_TOKEN_WRITE: upload_user_db_to_hf() except Exception as e: logging.error(f"Ошибка при сохранении данных пользователей: {e}", exc_info=True) # raise def upload_user_db_to_hf(): if not HF_TOKEN_WRITE: logging.info("HF_TOKEN_WRITE не установлен, загрузка пользовательской базы на Hugging Face пропущена.") return if not os.path.exists(USER_DATA_FILE): logging.warning(f"Файл {USER_DATA_FILE} не найден для загрузки на HF.") 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"Резервная копия JSON-базы данных пользователей загружена в {REPO_ID}.") except Exception as e: logging.error(f"Ошибка при загрузке резервной копии базы данных пользователей {USER_DATA_FILE}: {e}") def download_user_db_from_hf(): if not HF_TOKEN_READ: logging.info("HF_TOKEN_READ не установлен, загрузка пользовательской базы с Hugging Face пропущена.") return # Don't raise error try: logging.info(f"Попытка загрузки {USER_DATA_FILE} из {REPO_ID}...") hf_hub_download( repo_id=REPO_ID, filename=USER_DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, local_dir=".", force_filename=USER_DATA_FILE, # Ensure it overwrites with the correct name local_dir_use_symlinks=False ) logging.info(f"JSON-база данных пользователей '{USER_DATA_FILE}' успешно загружена из {REPO_ID}.") except RepositoryNotFoundError as e: logging.warning(f"Репозиторий {REPO_ID} (для пользователей) не найден на Hugging Face: {e}.") raise # Re-raise except Exception as e: logging.error(f"Ошибка при загрузке JSON-базы данных пользователей '{USER_DATA_FILE}' с Hugging Face: {e}") if "404" in str(e): logging.warning(f"Файл {USER_DATA_FILE} не найден в репозитории {REPO_ID}.") raise # Re-raise # --- Background Backup Thread --- # Global lock for saving data to prevent race conditions between request threads and backup thread data_lock = threading.Lock() user_data_lock = threading.Lock() def periodic_backup(): while True: time.sleep(900) # Backup every 15 minutes logging.info("Запуск периодического резервного копирования...") try: # No need to reload data here if save functions acquire lock # Just trigger the save which handles upload internally with data_lock: current_data = load_data() # Load latest state inside lock just before saving save_data(current_data) # save_data now includes upload if token exists with user_data_lock: current_user_data = load_user_data() # Load latest state inside lock save_user_data(current_user_data) # save_user_data includes upload if token exists logging.info("Периодическое резервное копирование завершено.") except Exception as e: logging.error(f"Ошибка во время периодического резервного копирования: {e}", exc_info=True) # --- Helper Functions --- def get_category_counts(products): counts = {} if not isinstance(products, list): # Add type check return counts for product in products: if isinstance(product, dict): # Check if product is a dict category = product.get('category', 'Без категории') counts[category] = counts.get(category, 0) + 1 return counts # --- User Management Functions --- # Wrap data saving in locks to ensure thread safety def register_user(login, password, phone, address): with user_data_lock: user_data_dict = load_user_data() # Load latest data inside lock users = user_data_dict.get('users', []) if any(user.get('login') == login for user in users if isinstance(user, dict)): return False, "Логин уже занят." if len(password) < 6: # Basic password length check return False, "Пароль должен быть не менее 6 символов." hashed_password = generate_password_hash(password) new_user = { 'login': login, 'password': hashed_password, 'phone': phone, 'address': address, 'points': 0, 'order_history': [] } # Ensure users is a list before appending if not isinstance(users, list): users = [] users.append(new_user) user_data_dict['users'] = users save_user_data(user_data_dict) # Save data inside lock return True, "Регистрация прошла успешно." def authenticate_user(login, password): # No lock needed for read-only operations usually, but load_user_data handles potential downloads user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): return None # Safety check for user in users: if isinstance(user, dict) and user.get('login') == login: if check_password_hash(user.get('password', ''), password): return user # Return the user dictionary return None def get_user_profile(login): # Read-only, no lock needed unless concerned about reads during backup write user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): return None # Safety check for user in users: if isinstance(user, dict) and user.get('login') == login: return user return None def update_user_profile(login, phone, address): user_found = False with user_data_lock: user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): save_user_data(user_data_dict) # Save even if structure was bad return False, "Ошибка структуры данных пользователей." for user in users: if isinstance(user, dict) and user.get('login') == login: user['phone'] = phone user['address'] = address user_found = True break if user_found: user_data_dict['users'] = users # Ensure the modified list is assigned back save_user_data(user_data_dict) # Save updated data return True, "Профиль обновлен." else: # Save data even if user not found, in case load fixed something save_user_data(user_data_dict) return False, "Пользователь не найден." def add_points_to_user(login, points): user_found = False with user_data_lock: user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): save_user_data(user_data_dict) return False # Indicate failure for user in users: if isinstance(user, dict) and user.get('login') == login: user['points'] = user.get('points', 0) + points user_found = True break if user_found: user_data_dict['users'] = users save_user_data(user_data_dict) return True else: save_user_data(user_data_dict) return False # User not found def redeem_points_from_user(login, points_to_redeem): user_found = False success = False message = "Пользователь не найден." with user_data_lock: user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): save_user_data(user_data_dict) return False, "Ошибка структуры данных пользователей." for user in users: if isinstance(user, dict) and user.get('login') == login: user_found = True current_points = user.get('points', 0) if current_points >= points_to_redeem: user['points'] = current_points - points_to_redeem success = True message = "Баллы успешно списаны" else: message = "Недостаточно баллов для списания." break if user_found and success: user_data_dict['users'] = users save_user_data(user_data_dict) return True, message else: # Save even on failure if load fixed something or if user was found but points insufficient save_user_data(user_data_dict) return False, message def save_order_to_history(login, order_details): user_found = False # Use UTC for storage consistency timestamp_utc = datetime.now(timezone.utc) with user_data_lock: user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): save_user_data(user_data_dict) return False, "Ошибка структуры данных пользователей." for user in users: if isinstance(user, dict) and user.get('login') == login: if 'order_history' not in user or not isinstance(user['order_history'], list): user['order_history'] = [] # Ensure it exists and is a list # Add timestamp as ISO string (UTC) order_details_copy = order_details.copy() # Avoid modifying original dict if passed by reference order_details_copy['timestamp'] = timestamp_utc.isoformat() user['order_history'].append(order_details_copy) user_found = True break if user_found: user_data_dict['users'] = users save_user_data(user_data_dict) return True, "Заказ сохранен в истории." else: save_user_data(user_data_dict) return False, "Пользователь не найден." def get_order_history(login): # Read-only, lock might be overkill but load_user_data handles potential downloads user_data_dict = load_user_data() users = user_data_dict.get('users', []) if not isinstance(users, list): return [] user = next((user for user in users if isinstance(user, dict) and user.get('login') == login), None) if user: # Return history, newest first # Sort using the datetime filter function for robustness history = user.get('order_history', []) if not isinstance(history, list): return [] # Safety check # Default datetime for sorting if timestamp is missing or invalid min_utc_dt = datetime.min.replace(tzinfo=timezone.utc) sorted_history = sorted( [item for item in history if isinstance(item, dict)], # Filter out non-dict items key=lambda x: format_iso_datetime_filter(x.get('timestamp')) or min_utc_dt, reverse=True ) return sorted_history return [] # --- Flask Routes --- @app.route('/') def menu(): # Use lock for loading main data as it might involve download/initial creation with data_lock: data = load_data() products = data.get('products', []) categories = data.get('categories', []) news_list = data.get('news', []) # User data loading is handled within called functions (get_user_profile) logged_in = 'user_login' in session user_login = session.get('user_login') user_profile = get_user_profile(user_login) if logged_in else None user_points = user_profile.get('points', 0) if user_profile else 0 category_counts = get_category_counts(products) # Sort news by timestamp (creation time) descending, most recent first min_utc_dt = datetime.min.replace(tzinfo=timezone.utc) news_for_template = sorted( [item for item in news_list if isinstance(item, dict)], # Filter non-dict news key=lambda item: format_iso_datetime_filter(item.get('timestamp')) or min_utc_dt, reverse=True ) # HTML Template (incorporating fixes for header and logo animation) menu_html = ''' Ultra Flowers - Магазин Цветов

Ultra Flowers

Создаем красоту для каждого момента.

{% for category in categories %} {# Use .get for safety #} {% endfor %}
{% for product in products %} {% if product is mapping %} {# Check if product is a dictionary-like object #}
{% set photos = product.get('photos', []) %} {% if photos and photos is iterable and photos|length > 0 %} {{ product.get('name', 'Фото товара') }} {# Added onerror handler #} {% else %} Нет фото {% endif %}

{{ product.get('name', 'Без названия') }}

{{ product.get('price', 0)|int }} ₸
{# Format price #}

{{ product.get('description', 'Описание отсутствует.') }}

{% if logged_in %}
0
{% else %} {% endif %}
{% else %} {% endif %} {% endfor %} {% if not products %}

Извините, в данный момент нет доступных букетов.

{% endif %}
{# Updated jQuery version #} {# #} {# Popper not strictly needed for current setup #} ''' current_year = datetime.now().year # Pass necessary variables to the template return render_template_string( menu_html, products=products, categories=categories, category_counts=category_counts, repo_id=REPO_ID, LOGO_URL=LOGO_URL, logged_in=logged_in, user_login=user_login, user_points=user_points, user_profile=user_profile, news_for_template=news_for_template, current_year=current_year ) @app.route('/product/') def product_detail(index): # This route *could* load product details specifically for the modal via AJAX, # but the current implementation loads all product data initially and uses JS. # Keeping this route might be useful for direct linking or future enhancements. # For now, let's make it return the data needed by the JS `loadProductDetails` if called directly. with data_lock: # Use lock for reading main data data = load_data() products = data.get('products', []) if 0 <= index < len(products) and isinstance(products[index], dict): product = products[index] # Return JSON data for the specific product return jsonify(product) else: return jsonify({'error': 'Товар не найден или данные некорректны'}), 404 @app.route('/admin', methods=['GET', 'POST']) def admin(): # Basic Auth - Consider a more robust system for production auth = request.authorization # Load credentials securely from environment variables ADMIN_USERNAME = os.getenv("ADMIN_USER", "admin") # Default only for dev ADMIN_PASSWORD = os.getenv("ADMIN_PASS", "secret") # Default only for dev # Check if credentials are provided and valid if not auth or not (auth.username == ADMIN_USERNAME and auth.password == ADMIN_PASSWORD): # Log failed attempt, potentially rate-limit logging.warning(f"Failed admin login attempt for user: {auth.username if auth else 'None'} from IP: {request.remote_addr}") # Return 401 Unauthorized with WWW-Authenticate header to prompt login return ('Доступ запрещен. Требуется авторизация.', 401, {'WWW-Authenticate': 'Basic realm="Admin Login Required"'}) # --- If authenticated, proceed --- # Use locks for loading/saving data with data_lock: data = load_data() products = data.get('products', []) categories = data.get('categories', []) news_list = data.get('news', []) # Ensure data structures are lists if not isinstance(products, list): products = [] if not isinstance(categories, list): categories = [] if not isinstance(news_list, list): news_list = [] if request.method == 'POST': action = request.form.get('action') try: # --- Category Management --- if action == 'add_category': category_name = request.form.get('category_name', '').strip() if category_name and category_name not in categories: with data_lock: data = load_data() # Reload fresh data inside lock categories = data.get('categories', []) if not isinstance(categories, list): categories = [] # Ensure list if category_name not in categories: # Double check categories.append(category_name) data['categories'] = categories save_data(data) # Save inside lock else: logging.warning(f"Категория '{category_name}' уже существует (обнаружена при добавлении).") elif not category_name: logging.warning("Попытка добавить пустую категорию.") else: logging.warning(f"Категория '{category_name}' уже существует.") return redirect(url_for('admin')) elif action == 'delete_category': category_index_str = request.form.get('category_index') if category_index_str is not None: category_index = int(category_index_str) with data_lock: data = load_data() categories = data.get('categories', []) products = data.get('products', []) if not isinstance(categories, list): categories = [] if not isinstance(products, list): products = [] if 0 <= category_index < len(categories): category_to_delete = categories.pop(category_index) # Update products using this category updated_products = [] for product in products: if isinstance(product, dict) and product.get('category') == category_to_delete: product['category'] = 'Без категории' updated_products.append(product) data['categories'] = categories data['products'] = updated_products save_data(data) logging.info(f"Категория '{category_to_delete}' удалена.") else: logging.warning(f"Попытка удаления категории с неверным индексом: {category_index}") else: logging.warning("Индекс категории не предоставлен для удаления.") return redirect(url_for('admin')) # --- Product Management --- elif action == 'add': name = request.form.get('name', '').strip() price_str = request.form.get('price', '0').replace(',', '.').strip() price = float(price_str) if price_str else 0.0 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') photos_list = [] options_list = [] # Basic validation if not name or price < 0: logging.error("Ошибка добавления: Название и неотрицательная цена обязательны.") # Add flash message for user feedback here if desired return redirect(url_for('admin')) # Upload photos to HF if HF_TOKEN_WRITE: api = HfApi() uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) for photo in photos_files[:10]: # Limit photos if photo and photo.filename: # Sanitize filename and make unique base, ext = os.path.splitext(photo.filename) safe_base = secure_filename(base) photo_filename = f"{safe_base}_{int(time.time())}{ext}" temp_path = os.path.join(uploads_dir, photo_filename) try: photo.save(temp_path) api.upload_file( path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Добавлено фото для товара {name}" ) photos_list.append(photo_filename) logging.info(f"Загружено фото {photo_filename} на HF.") except Exception as upload_err: logging.error(f"Ошибка загрузки фото {photo_filename} на HF: {upload_err}") finally: if os.path.exists(temp_path): try: os.remove(temp_path) except OSError as rm_err: logging.error(f"Не удалось удалить временный файл {temp_path}: {rm_err}") # Clean up temp dir try: if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) except OSError as e: logging.error(f"Ошибка удаления временной папки {uploads_dir}: {e}") elif photos_files and any(p.filename for p in photos_files): logging.warning("HF_TOKEN_WRITE не установлен, загрузка фото пропущена.") # Process options for opt_name, opt_price_str in zip(option_names, option_prices): opt_name = opt_name.strip() opt_price_str = opt_price_str.replace(',', '.').strip() if opt_name and opt_price_str is not None: # Ensure price string exists try: options_list.append({ 'name': opt_name, 'price': float(opt_price_str) }) except ValueError: logging.warning(f"Неверный формат цены для опции '{opt_name}': '{opt_price_str}'. Опция пропущена.") new_product = { 'name': name, 'price': price, 'description': description, 'category': category if category in categories else 'Без категории', 'photos': photos_list, 'options': options_list } with data_lock: data = load_data() products = data.get('products', []) if not isinstance(products, list): products = [] products.append(new_product) data['products'] = products save_data(data) logging.info(f"Добавлен товар: {name}") return redirect(url_for('admin')) elif action == 'edit': product_index_str = request.form.get('product_index') if product_index_str is not None: product_index = int(product_index_str) with data_lock: data = load_data() products = data.get('products', []) categories = data.get('categories', []) # Load categories too for validation if not isinstance(products, list): products = [] if not isinstance(categories, list): categories = [] if 0 <= product_index < len(products) and isinstance(products[product_index], dict): product = products[product_index] # Get the product dict # Update fields similar to 'add' action new_name = request.form.get('name', product.get('name', '')).strip() price_str = request.form.get('price', str(product.get('price', 0))).replace(',', '.') new_price = float(price_str) if price_str else product.get('price', 0) new_description = request.form.get('description', product.get('description', '')).strip() new_category = request.form.get('category', product.get('category', 'Без категории')) # Basic validation if not new_name or new_price < 0: logging.error("Ошибка редактирования: Название и неотрицательная цена обязательны.") # Add flash message return redirect(url_for('admin')) product['name'] = new_name product['price'] = new_price product['description'] = new_description product['category'] = new_category if new_category in categories else 'Без категории' # Handle photos: keep existing + add new existing_photos_to_keep = request.form.getlist('existing_photos') new_photos_files = request.files.getlist('photos') # Filter out any empty strings or invalid values from existing_photos_to_keep current_photos = [p for p in existing_photos_to_keep if p and isinstance(p, str)] # Upload new photos if HF_TOKEN_WRITE and new_photos_files: api = HfApi() uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) photo_limit = 10 for photo in new_photos_files: if len(current_photos) >= photo_limit: logging.warning(f"Достигнут лимит фото ({photo_limit}) для товара {product['name']}. Остальные фото пропущены.") break if photo and photo.filename: base, ext = os.path.splitext(photo.filename) safe_base = secure_filename(base) photo_filename = f"{safe_base}_{int(time.time())}{ext}" temp_path = os.path.join(uploads_dir, photo_filename) try: photo.save(temp_path) api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{photo_filename}", repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, commit_message=f"Обновлено фото для товара {product['name']}") current_photos.append(photo_filename) logging.info(f"Загружено новое фото {photo_filename} при редактировании.") except Exception as upload_err: logging.error(f"Ошибка загрузки фото {photo_filename} на HF при редактировании: {upload_err}") finally: if os.path.exists(temp_path): try: os.remove(temp_path) except OSError as rm_err: logging.error(f"Не удалось удалить {temp_path}: {rm_err}") # Clean up temp dir try: if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) except OSError as e: logging.error(f"Ошибка удаления временной папки {uploads_dir}: {e}") elif new_photos_files and any(p.filename for p in new_photos_files): logging.warning("HF_TOKEN_WRITE не установлен, загрузка новых фото при редактировании пропущена.") product['photos'] = current_photos # Update options (replace all existing with new list from form) option_names = request.form.getlist('option_names') option_prices = request.form.getlist('option_prices') options_list = [] for opt_name, opt_price_str in zip(option_names, option_prices): opt_name = opt_name.strip() opt_price_str = opt_price_str.replace(',', '.').strip() if opt_name and opt_price_str is not None: try: options_list.append({'name': opt_name, 'price': float(opt_price_str)}) except ValueError: logging.warning(f"Неверный формат цены для опции '{opt_name}': '{opt_price_str}' при редактировании. Опция пропущена.") product['options'] = options_list # products[product_index] = product # Update the list (already modified by reference) data['products'] = products # Assign back just in case save_data(data) # Save changes logging.info(f"Товар '{product['name']}' (индекс {product_index}) обновлен.") else: logging.warning(f"Попытка редактирования товара с неверным индексом или неверными данными: {product_index}") else: logging.warning("Индекс товара не предоставлен для редактирования.") return redirect(url_for('admin')) elif action == 'delete': product_index_str = request.form.get('product_index') if product_index_str is not None: product_index = int(product_index_str) deleted_product_name = "N/A" photos_to_delete = [] with data_lock: data = load_data() products = data.get('products', []) if not isinstance(products, list): products = [] if 0 <= product_index < len(products): # Optionally: Delete associated photos from HF? Risky. # For now, just remove the product entry. deleted_product = products.pop(product_index) deleted_product_name = deleted_product.get('name', 'N/A') photos_to_delete = deleted_product.get('photos', []) # Get photos for potential later deletion data['products'] = products save_data(data) logging.info(f"Удален товар: {deleted_product_name} (индекс {product_index})") # Add logic here later to delete photos_to_delete from HF if desired else: logging.warning(f"Попытка удаления товара с неверным индексом: {product_index}") else: logging.warning("Индекс товара не предоставлен для удаления.") return redirect(url_for('admin')) # --- News Management --- elif action == 'add_news': news_title = request.form.get('news_title', '').strip() news_text = request.form.get('news_text', '').strip() # Consider sanitizing HTML news_photo_file = request.files.get('news_photo') # Get expiry values safely, default to 0 expiry_days = int(request.form.get('expiry_days') or 0) expiry_hours = int(request.form.get('expiry_hours') or 0) expiry_minutes = int(request.form.get('expiry_minutes') or 0) if not news_title or not news_text: logging.error("Ошибка добавления новости: Заголовок и текст обязательны.") # Add flash message return redirect(url_for('admin')) news_photo_filename = None if HF_TOKEN_WRITE and news_photo_file and news_photo_file.filename: base, ext = os.path.splitext(news_photo_file.filename) safe_base = secure_filename(base) news_photo_filename = f"news_{safe_base}_{int(time.time())}{ext}" uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) temp_path = os.path.join(uploads_dir, news_photo_filename) try: news_photo_file.save(temp_path) api = HfApi() api.upload_file(path_or_fileobj=temp_path, path_in_repo=f"photos/{news_photo_filename}", 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 upload_err: logging.error(f"Ошибка загрузки фото новости {news_photo_filename} на HF: {upload_err}") news_photo_filename = None # Reset filename if upload failed finally: if os.path.exists(temp_path): try: os.remove(temp_path) except OSError as rm_err: logging.error(f"Не удалось удалить {temp_path}: {rm_err}") try: if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) except OSError as e: logging.error(f"Ошибка удаления временной папки {uploads_dir}: {e}") elif news_photo_file and news_photo_file.filename: logging.warning("HF_TOKEN_WRITE не установлен, загрузка фото новости пропущена.") expiry_time = None total_delta = timedelta(days=expiry_days, hours=expiry_hours, minutes=expiry_minutes) if total_delta > timedelta(0): # Store expiry in UTC expiry_time = datetime.now(timezone.utc) + total_delta new_news_item = { 'title': news_title, 'text': news_text, # Be careful with HTML injection if not sanitized 'photo': news_photo_filename, # Store as ISO string (UTC) or None 'expiry': expiry_time.isoformat() if expiry_time else None, 'timestamp': datetime.now(timezone.utc).isoformat() # Add creation timestamp (UTC ISO) } with data_lock: data = load_data() news_list = data.get('news', []) if not isinstance(news_list, list): news_list = [] news_list.append(new_news_item) data['news'] = news_list save_data(data) logging.info(f"Добавлена новость: {news_title}") return redirect(url_for('admin')) elif action == 'delete_news': news_index_str = request.form.get('news_index') # This index refers to the sorted list in the admin view if news_index_str is not None: news_index_view = int(news_index_str) with data_lock: data = load_data() all_news_raw = data.get('news', []) if not isinstance(all_news_raw, list): all_news_raw = [] # Re-sort raw news like in admin view to find the correct item min_utc_dt = datetime.min.replace(tzinfo=timezone.utc) sorted_news_for_admin = sorted( [item for item in all_news_raw if isinstance(item, dict)], key=lambda item: format_iso_datetime_filter(item.get('timestamp')) or min_utc_dt, reverse=True ) if 0 <= news_index_view < len(sorted_news_for_admin): item_to_delete = sorted_news_for_admin[news_index_view] # Find the item in the original raw list to remove it # This assumes timestamps or titles are unique enough, might need a unique ID later original_index_to_delete = -1 for i, item in enumerate(all_news_raw): if item == item_to_delete: # Compare dictionaries original_index_to_delete = i break if original_index_to_delete != -1: deleted_news = all_news_raw.pop(original_index_to_delete) logging.info(f"Удалена новость: {deleted_news.get('title', 'N/A')}") # Consider deleting photo from HF here if needed data['news'] = all_news_raw # Update the main data dictionary save_data(data) else: logging.error(f"Не удалось найти новость для удаления в исходном списке (индекс вида {news_index_view}).") else: logging.warning(f"Попытка удаления новости с неверным индексом вида: {news_index_view}") else: logging.warning("Индекс новости не предоставлен для удаления.") return redirect(url_for('admin')) except ValueError as ve: logging.error(f"Ошибка преобразования значения в админке (action={action}): {ve}") # Optionally: add flash message to show user except Exception as e: logging.error(f"Непредвиденная ошибка в админке (action={action}): {e}", exc_info=True) # Optionally: add flash message # Redirect even on error for simplicity, consider flash messages return redirect(url_for('admin')) # --- Admin Panel HTML --- # Sort news list for display in admin panel (use the same sorting as main page) min_utc_dt = datetime.min.replace(tzinfo=timezone.utc) admin_news_list = sorted( [item for item in news_list if isinstance(item, dict)], # Filter non-dicts key=lambda item: format_iso_datetime_filter(item.get('timestamp')) or min_utc_dt, reverse=True ) # Sort products alphabetically for admin view admin_products_list = sorted( [p for p in products if isinstance(p, dict)], # Filter non-dicts key=lambda p: p.get('name', '').lower() ) admin_html = ''' Админ-панель - Ultra Flowers

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

ℹ️ Если кнопки "Редактировать", "Удалить" или "+ Добавить опцию" не работают, проверьте консоль разработчика в браузере (обычно клавиша F12) на наличие ошибок JavaScript.

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

{# Explicit action URL #}

Существующие категории:

{% for category in categories %}
{{ category }}
{# Explicit action URL #}
{% else %}

Нет категорий.

{% endfor %}

Добавить Товар (Букет/Композицию)

{# Explicit action URL #}
Можно выбрать несколько файлов.

Список Товаров ({{ admin_products_list|length }})

{% for product in admin_products_list %} {# Using sorted list #} {% if product is mapping %} {# Check if product is dict #}
{% set photos = product.get('photos', []) %} {% if photos and photos is iterable and photos|length > 0 %} {{ product.get('name', '') }} {% else %}
Нет фото
{% endif %}
{{ product.get('name', 'Без названия') }} ({{ product.get('category', 'Без категории') }}) - {{ product.get('price', 0) }} ₸ {{ product.get('description', '')[:100] }}{% if product.get('description', '')|length > 100 %}...{% endif %} {% set options = product.get('options', []) %} {% if options and options is iterable and options|length > 0 %} Опции: {{ options|map(attribute='name')|join(', ') }} {% endif %}
{# FIX 1: Ensure onclick calls openEditModal with the correct ORIGINAL index #} {# Find original index #}
{# Explicit action URL #} {# FIX 1: Use the ORIGINAL index for deletion #} {# Find original index #}
{% endif %} {# End check if product is mapping #} {% else %}

Нет добавленных товаров.

{% endfor %}

Управление Новостями и Акциями

{# Explicit action URL #}

Существующие новости ({{ admin_news_list|length }}):

{% for news_item in admin_news_list %} {# Using sorted list for display #} {% if news_item is mapping %} {# Check if item is dict #}
{% set photo = news_item.get('photo') %} {% if photo %} {{ news_item.get('title', '') }} {% else %}
{% endif %}
{{ news_item.get('title', 'Без заголовка') }} {{ news_item.get('text', '')[:150] | safe }}{% if news_item.get('text', '')|length > 150 %}...{% endif %} {# --- UPDATED DATE FORMATTING using the registered filter --- #} {% set ts_dt = news_item.timestamp | format_iso_datetime %} Добавлено: {{ ts_dt.strftime('%d.%m.%Y %H:%M') if ts_dt else 'N/A' }} UTC {% set expiry = news_item.get('expiry') %} {% if expiry %} {% set expiry_dt = expiry | format_iso_datetime %} {% if expiry_dt %} Актуально до: {{ expiry_dt.strftime('%d.%m.%Y %H:%M') }} UTC {% endif %} {% endif %} {# --- END UPDATED DATE FORMATTING --- #}
{# Explicit action URL #} {# Pass the index FROM THE SORTED LIST VIEW #}
{% endif %} {# End check if item is mapping #} {% else %}

Нет добавленных новостей.

{% endfor %}
''' return render_template_string( admin_html, products=products, # Pass original list for index finding admin_products_list=admin_products_list, # Pass sorted list for display categories=categories, repo_id=REPO_ID, admin_news_list=admin_news_list # Pass sorted news list ) # --- API Routes (Login, Register, Profile, Points, History) --- @app.route('/register', methods=['POST']) def register(): # Simplified data extraction login = request.form.get('registerLogin') password = request.form.get('registerPassword') phone = request.form.get('registerPhone') address = request.form.get('registerAddress') if not all([login, password, phone, address]): return jsonify({'status': 'error', 'message': 'Все поля обязательны для заполнения.'}), 400 # Input validation (basic) if len(password) < 6: return jsonify({'status': 'error', 'message': 'Пароль должен быть не менее 6 символов.'}), 400 if len(login) < 3: return jsonify({'status': 'error', 'message': 'Логин должен быть не менее 3 символов.'}), 400 success, message = register_user(login, password, phone, address) status_code = 201 if success else 409 # Use 409 Conflict if user exists return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code @app.route('/login', methods=['POST']) def login(): # Simplified data extraction login_username = request.form.get('loginUsername') login_password = request.form.get('loginPassword') if not login_username or not login_password: return jsonify({'status': 'error', 'message': 'Логин и пароль обязательны.'}), 400 user = authenticate_user(login_username, login_password) if user: session.permanent = True # Make session persistent (uses app.permanent_session_lifetime) session['user_login'] = user['login'] # Store login in session AFTER setting permanent logging.info(f"Пользователь '{user['login']}' успешно вошел в систему. Session ID: {session.sid if hasattr(session, 'sid') else 'N/A'}") return jsonify({'status': 'success', 'message': 'Вход выполнен успешно.'}) else: logging.warning(f"Неудачная попытка входа для пользователя '{login_username}'.") return jsonify({'status': 'error', 'message': 'Неверный логин или пароль.'}), 401 @app.route('/logout') def logout(): user = session.pop('user_login', None) session.clear() # Clear entire session for good measure if user: logging.info(f"Пользователь '{user}' вышел из системы.") # Redirect to main page after logout return redirect(url_for('menu')) @app.route('/update_profile', methods=['POST']) def update_profile(): if 'user_login' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 login = session['user_login'] # Simplified data extraction phone = request.form.get('editPhone') address = request.form.get('editAddress') if not phone or not address: return jsonify({'status': 'error', 'message': 'Телефон и адрес обязательны.'}), 400 success, message = update_user_profile(login, phone, address) if success: logging.info(f"Профиль пользователя {login} обновлен.") return jsonify({'status': 'success', 'message': message}), 200 else: # Log failure reason if possible (e.g., user not found, save error) logging.warning(f"Не удалось обновить профиль для {login}: {message}") # Determine appropriate status code (404 if not found, 500 if save failed) status_code = 404 if "не найден" in message else 500 return jsonify({'status': 'error', 'message': message}), status_code @app.route('/add_points', methods=['POST']) def add_points(): if 'user_login' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 login = session['user_login'] try: # Assume form data from finalizeOrderWhatsApp AJAX call points_str = request.form.get('points') if points_str is None: return jsonify({'status': 'error', 'message': 'Параметр points отсутствует.'}), 400 points = int(points_str) if points < 0: # Allow 0 points (e.g., if order total was 0 after redemption) return jsonify({'status': 'error', 'message': 'Нельзя начислить отрицательные баллы.'}), 400 except ValueError: return jsonify({'status': 'error', 'message': 'Неверное количество баллов.'}), 400 success = add_points_to_user(login, points) if success: logging.info(f"Начислено {points} баллов пользователю {login}") return jsonify({'status': 'success', 'message': f'{points} баллов начислено.'}) else: # This case should ideally not happen if user is logged in, means data inconsistency logging.error(f"Ошибка начисления баллов: пользователь {login} не найден при попытке добавления.") return jsonify({'status': 'error', 'message': 'Ошибка начисления баллов: пользователь не найден.'}), 500 @app.route('/redeem_points', methods=['POST']) def redeem_points(): if 'user_login' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 login = session['user_login'] try: # Assume form data from orderViaWhatsApp AJAX call points_str = request.form.get('points') if points_str is None: return jsonify({'status': 'error', 'message': 'Параметр points отсутствует.'}), 400 points = int(points_str) if points <= 0: # Must redeem a positive amount return jsonify({'status': 'error', 'message': 'Количество списываемых баллов должно быть положительным.'}), 400 except ValueError: return jsonify({'status': 'error', 'message': 'Неверное количество баллов.'}), 400 success, message = redeem_points_from_user(login, points) status_code = 200 if success else 400 # 400 for insufficient points or user error if success: logging.info(f"Списано {points} баллов у пользователя {login}") else: logging.warning(f"Ошибка списания {points} баллов у пользователя {login}: {message}") return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code @app.route('/save_order_history', methods=['POST']) def save_order_history_route(): # Renamed route function slightly if 'user_login' not in session: return jsonify({'status': 'error', 'message': 'Не авторизован'}), 401 login = session['user_login'] if not request.is_json: logging.warning(f"Получен не-JSON запрос на /save_order_history от {login}") return jsonify({'status': 'error', 'message': 'Запрос должен быть в формате JSON.'}), 400 order_details = request.get_json() # Validate basic structure if not isinstance(order_details, dict) or not isinstance(order_details.get('items'), list) or not isinstance(order_details.get('total_amount'), (int, float)): logging.warning(f"Получены неполные или некорректные данные заказа для сохранения в историю от {login}: {order_details}") return jsonify({'status': 'error', 'message': 'Неполные или некорректные данные заказа.'}), 400 success, message = save_order_to_history(login, order_details) status_code = 201 if success else 500 # 500 if save failed unexpectedly if success: logging.info(f"Заказ сохранен в историю для пользователя {login}") else: logging.error(f"Ошибка сохранения заказа в историю для пользователя {login}: {message}") return jsonify({'status': 'success' if success else 'error', 'message': message}), status_code # --- App Initialization --- if __name__ == '__main__': # Start background backup thread only if HF tokens are set for writing if HF_TOKEN_WRITE: backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() logging.info("Поток периодического резервного копирования запущен.") else: logging.info("HF_TOKEN_WRITE не установлен, резервное копирование на HF отключено.") # Use Waitress or Gunicorn for production instead of Flask's built-in server # For development: port = int(os.environ.get("PORT", 7860)) # Use environment variable for port # Set debug=False for production/semi-production, host='0.0.0.0' for external access app.run(debug=False, host='0.0.0.0', port=port)