diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,5 +1,5 @@ # --- START OF FILE app.py --- -from flask import Flask, render_template_string, request, redirect, url_for, jsonify +from flask import Flask, render_template_string, request, redirect, url_for import json import os import logging @@ -7,12 +7,11 @@ import threading import time from datetime import datetime from huggingface_hub import HfApi, hf_hub_download -from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError +from huggingface_hub.utils import RepositoryNotFoundError from werkzeug.utils import secure_filename app = Flask(__name__) DATA_FILE = 'data.json' -UPLOADS_DIR = 'uploads' REPO_ID = "Kgshop/teenager" HF_TOKEN_WRITE = os.getenv("HF_TOKEN") @@ -20,195 +19,136 @@ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") LOGO_URL = "https://huggingface.co/spaces/Teenagerkg/optom/resolve/main/PXL_20250419_062456154~2.jpg" -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig(level=logging.INFO) # Use INFO level for production -# Ensure necessary directories exist -os.makedirs(UPLOADS_DIR, exist_ok=True) - -# --- Data Handling --- data_lock = threading.Lock() -def download_db_from_hf(retries=3, delay=5): - for attempt in range(retries): - try: - logging.info(f"Attempting to download {DATA_FILE} from {REPO_ID} (Attempt {attempt + 1}/{retries})") - 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, # Ensure we get the latest version - etag_timeout=60 # Increase timeout - ) - logging.info("JSON database successfully downloaded from Hugging Face.") - return True - except RepositoryNotFoundError as e: - logging.error(f"Repository {REPO_ID} not found: {e}") - # Don't retry if repo not found - return False - except HfHubHTTPError as e: - # Specific handling for HTTP errors which might be transient - logging.error(f"HTTP error during download: {e}. Retrying in {delay} seconds...") - time.sleep(delay) - except Exception as e: - logging.error(f"Error downloading JSON database (Attempt {attempt + 1}): {e}") - if attempt < retries - 1: - logging.info(f"Retrying download in {delay} seconds...") - time.sleep(delay) - else: - logging.error("Download failed after multiple retries.") - return False - return False +def download_db_from_hf(): + 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=".", + local_dir_use_symlinks=False, + force_download=True, # Ensure latest version is fetched + cache_dir=None # Avoid caching issues if tokens change or file updates + ) + logging.info("JSON база успешно скачана из Hugging Face.") + except RepositoryNotFoundError as e: + logging.error(f"Репозиторий не найден: {e}") + raise + except Exception as e: + logging.error(f"Ошибка при скачивании JSON базы: {e}") + raise +def upload_db_to_hf_internal(): + # Assumes data_lock is already held + if not os.path.exists(DATA_FILE): + logging.warning(f"Локальный файл {DATA_FILE} не найден для загрузки.") + 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("Резервная копия JSON базы успешно загружена на Hugging Face.") + except Exception as e: + logging.error(f"Ошибка при загрузке резервной копии: {e}") + # Do not raise here, just log, might be called from different contexts def load_data(): - with data_lock: - if not os.path.exists(DATA_FILE): - logging.warning(f"Local file {DATA_FILE} not found. Attempting download.") - if not download_db_from_hf(): - logging.warning("Download failed. Initializing with empty data.") - return {'products': [], 'categories': []} - elif (time.time() - os.path.getmtime(DATA_FILE)) > 600: # Refresh if older than 10 mins - logging.info("Local file is older than 10 minutes. Attempting refresh download.") - download_db_from_hf() # Try to refresh, ignore failure, proceed with local cache + initial_load_error = None + try: + with data_lock: + download_db_from_hf() + except RepositoryNotFoundError: + logging.info("Репозиторий не найден. Будет использоваться локальный файл, если он существует.") + initial_load_error = RepositoryNotFoundError("Repo not found") + except Exception as e: + logging.error(f"Ошибка скачивания базы данных при запуске: {e}. Попытка использовать локальный файл.") + initial_load_error = e + # Always try to read the local file after attempting download or if download failed + if os.path.exists(DATA_FILE): try: with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) - logging.info("Data successfully loaded from JSON") + logging.info("Данные успешно загружены из локального JSON") + if not isinstance(data, dict): - logging.warning("Data is not a dictionary, re-initializing structure.") - return {'products': [], 'categories': [] if not isinstance(data, list) else data} - if 'products' not in data: + logging.warning("Структура данных не является словарем, инициализация...") + return {'products': [], 'categories': []} + + # Ensure keys exist and are lists + if 'products' not in data or not isinstance(data['products'], list): data['products'] = [] - logging.warning("Missing 'products' key, initialized.") - if 'categories' not in data: + if 'categories' not in data or not isinstance(data['categories'], list): data['categories'] = [] - logging.warning("Missing 'categories' key, initialized.") - # Ensure products is a list - if not isinstance(data['products'], list): - logging.warning("'products' is not a list, re-initializing.") - data['products'] = [] - # Ensure categories is a list - if not isinstance(data['categories'], list): - logging.warning("'categories' is not a list, re-initializing.") - data['categories'] = [] return data - except FileNotFoundError: - logging.error("Local database file not found even after download attempt.") - return {'products': [], 'categories': []} except json.JSONDecodeError: - logging.error("Error: Cannot decode JSON file. Returning empty data.") - # Optionally try downloading again or backup the corrupted file + logging.error("Ошибка: Невозможно декодировать локальный JSON файл. Создается пустая структура.") return {'products': [], 'categories': []} except Exception as e: - logging.error(f"An unexpected error occurred during data loading: {e}") + logging.error(f"Произошла ошибка при загрузке локальных данных: {e}") return {'products': [], 'categories': []} + else: + # If file doesn't exist (either download failed or repo not found) + logging.warning(f"Локальный файл {DATA_FILE} не найден. Создается пустая структура данных.") + if initial_load_error and isinstance(initial_load_error, RepositoryNotFoundError): + # Create an empty file if the repo wasn't found and no local file exists + try: + with open(DATA_FILE, 'w', encoding='utf-8') as f: + json.dump({'products': [], 'categories': []}, f) + logging.info(f"Создан пустой локальный файл {DATA_FILE}.") + except Exception as e_create: + logging.error(f"Не удалось создать пустой локальный файл: {e_create}") + return {'products': [], 'categories': []} def save_data(data): with data_lock: try: - # Create a backup before overwriting - if os.path.exists(DATA_FILE): - backup_file = f"{DATA_FILE}.bak_{int(time.time())}" - os.rename(DATA_FILE, backup_file) - logging.info(f"Created backup: {backup_file}") - - with open(DATA_FILE, 'w', encoding='utf-8') as file: + temp_file = DATA_FILE + '.tmp' + with open(temp_file, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) - logging.info("Data successfully saved to JSON") - # Trigger immediate upload after saving - upload_db_to_hf() + # Atomic replace + os.replace(temp_file, DATA_FILE) + logging.info("Данные успешно сохранены в JSON") + upload_db_to_hf_internal() except Exception as e: - logging.error(f"Error saving data: {e}") - # Attempt to restore backup if save failed - if 'backup_file' in locals() and os.path.exists(backup_file): - try: - os.rename(backup_file, DATA_FILE) - logging.info("Restored data from backup due to save error.") - except Exception as restore_e: - logging.error(f"Failed to restore backup: {restore_e}") - raise # Re-raise the original exception - - -def upload_db_to_hf(): - if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.") - return False - if not os.path.exists(DATA_FILE): - logging.warning(f"{DATA_FILE} not found locally. Skipping upload.") - return False - - 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"Automatic database backup {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", - commit_description="Regular automatic backup of the application data." - ) - logging.info("JSON database backup successfully uploaded to Hugging Face.") - return True - except Exception as e: - logging.error(f"Error uploading backup to Hugging Face: {e}") - return False - -def upload_photo_to_hf(local_path, repo_filename, product_name): - if not HF_TOKEN_WRITE: - logging.warning("HF_TOKEN_WRITE not set. Skipping photo upload.") - return False - try: - api = HfApi() - api.upload_file( - path_or_fileobj=local_path, - path_in_repo=f"photos/{repo_filename}", - repo_id=REPO_ID, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Upload photo {repo_filename} for product {product_name}" - ) - logging.info(f"Photo {repo_filename} uploaded successfully to HF.") - return True - except Exception as e: - logging.error(f"Error uploading photo {repo_filename} to HF: {e}") - return False - finally: - # Clean up local temporary file - if os.path.exists(local_path): - try: - os.remove(local_path) - logging.info(f"Removed temporary local file: {local_path}") - except OSError as e: - logging.error(f"Error removing temporary file {local_path}: {e}") - + logging.error(f"Ошибка при сохранении данных: {e}") + # Clean up temp file if it exists + if os.path.exists(temp_file): + try: + os.remove(temp_file) + except OSError as e_rem: + logging.error(f"Ошибка при удалении временного файла {temp_file}: {e_rem}") + raise # Re-raise after logging and cleanup attempt def periodic_backup(): while True: - time.sleep(900) # Backup every 15 minutes - logging.info("Starting periodic background backup...") - upload_db_to_hf() - - -# --- Flask Routes --- + time.sleep(800) + logging.info("Запуск периодического резервного копирования...") + with data_lock: + try: + upload_db_to_hf_internal() + except Exception as e: + logging.error(f"Ошибка в потоке периодического бэкапа: {e}") @app.route('/') def catalog(): data = load_data() - # Sort products by added_at date, newest first. Handle missing dates gracefully. - products = sorted( - [p for p in data.get('products', []) if isinstance(p, dict)], # Ensure items are dicts - key=lambda x: x.get('added_at', '1970-01-01T00:00:00'), # Default old date if missing - reverse=True - ) + products = sorted(data.get('products', []), key=lambda x: x.get('added_at', ''), reverse=True) categories = data.get('categories', []) - if not isinstance(categories, list): categories = [] # Ensure categories is a list catalog_html = ''' @@ -216,22 +156,21 @@ def catalog(): - TeenAger - детская одежда оптом + TeenAger - детская одежда оптом - +
- +

Каталог товаров

{% for category in categories %} - + {% endfor %}
- +
{% for product in products %}
- {% set photos = product.get('photos', []) %} + data-name="{{ product['name']|lower|e }}" + data-description="{{ product['description']|lower|e }}" + data-category="{{ product.get('category', 'Без категории')|e }}"> + {% if product.get('photos') and product['photos']|length > 0 %}
- {% if photos and photos|length > 0 %} - {{ product.get('name', 'Product Image') }} - {% else %} - No Image Available - {% endif %} -
-

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

-
{{ product.get('price', 0)|float }} с
-

{{ product.get('description', 'Нет описания') }}

-
- - + {{ product['name']|e }}
+ {% endif %} +

{{ product['name']|e }}

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

{{ product['description'][:50]|e }}{% if product['description']|length > 50 %}...{% endif %}

+ +
+ {% else %} +

Товары не найдены.

{% endfor %}
@@ -371,12 +297,10 @@ def catalog(): ×

Корзина

-
- Итого: 0 с -
- - -
+
+ Итого: 0 с + +
@@ -386,9 +310,18 @@ def catalog(): ''' - return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID) @app.route('/product/') -def product_detail_endpoint(index): - # This endpoint is now effectively handled by JS rendering in the modal - # Keeping it might be useful for direct linking or SEO if needed later, - # but for now, it's not directly used by the main catalog page modal. - # If kept, ensure load_data() is called here too. +def product_detail(index): data = load_data() products = data.get('products', []) - if not isinstance(products, list) or not 0 <= index < len(products): - return jsonify({"error": "Product not found"}), 404 + if not 0 <= index < len(products): + return "Продукт не найден", 404 product = products[index] - # Simple JSON response might be better if called via fetch - return jsonify(product) + detail_html = ''' +
+

{{ product['name']|e }}

+
+
+ {% set photos = product.get('photos', []) %} + {% if photos %} + {% for photo in photos %} +
+
+ {{ product['name']|e }} +
+
+ {% endfor %} + {% else %} +
+ Нет изображения +
+ {% endif %} +
+ {% if photos and photos|length > 1 %} +
+
+
+ {% endif %} +
+
+

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

+

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

+

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

+

Доступные цвета: {{ (product.get('colors', [])|map('escape')|join(', ')) if product.get('colors') else 'Нет цветов' }}

+
+
+ ''' + return render_template_string(detail_html, product=product, repo_id=REPO_ID) @app.route('/admin', methods=['GET', 'POST']) @@ -708,187 +622,195 @@ def admin(): data = load_data() products = data.get('products', []) categories = data.get('categories', []) - if not isinstance(products, list): products = [] - if not isinstance(categories, list): categories = [] - if request.method == 'POST': action = request.form.get('action') - logging.info(f"Admin action received: {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) - categories.sort() # Keep categories sorted - try: + try: + 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) - logging.info(f"Category '{category_name}' added.") - except Exception as e: - logging.error(f"Failed to save data after adding category: {e}") - return "Ошибка сохранения данных при добавлении категории", 500 + logging.info(f"Категория '{category_name}' добавлена.") + elif not category_name: + return "Ошибка: Название категории не может быть пустым", 400 + else: + return f"Ошибка: Категория '{category_name}' уже существует", 400 return redirect(url_for('admin')) - elif not category_name: - return "Ошибка: Название категории не может быть пустым", 400 - else: - return "Ошибка: Категория с таким названием уже существует", 400 - - elif action == 'delete_category': - try: - category_index = int(request.form.get('category_index')) + elif action == 'delete_category': + category_index_str = request.form.get('category_index') + category_index = int(category_index_str) if 0 <= category_index < len(categories): deleted_category = categories.pop(category_index) - # Update products that used this category for product in products: - if isinstance(product, dict) and product.get('category') == deleted_category: + if product.get('category') == deleted_category: product['category'] = 'Без категории' - try: - save_data(data) - logging.info(f"Category '{deleted_category}' deleted.") - except Exception as e: - logging.error(f"Failed to save data after deleting category: {e}") - # Attempt to revert category deletion in memory if save failed? Complex. - return "Ошибка сохранения данных при удалении категории", 500 - return redirect(url_for('admin')) + save_data(data) + logging.info(f"Категория '{deleted_category}' удалена.") else: return "Ошибка: Неверный индекс категории", 400 - except (ValueError, TypeError): - return "Ошибка: Неверный индекс категории", 400 - - - elif action == 'add' or action == 'edit': - try: - index = -1 # Default for 'add' - if action == 'edit': - index = int(request.form.get('index')) - if not (isinstance(products, list) and 0 <= index < len(products)): - return "Ошибка: Неверный индекс товара для редактирования", 400 + return redirect(url_for('admin')) + elif action == 'add': name = request.form.get('name', '').strip() price_str = request.form.get('price', '').replace(',', '.') description = request.form.get('description', '').strip() category = request.form.get('category', 'Без категории') photos_files = request.files.getlist('photos') - # Get colors, strip whitespace, filter empty strings colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] + photos_list = [] if not name or not price_str or not description: - return "Ошибка: Название, цена и описание обязательны", 400 + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание)", 400 try: - price_float = float(price_str) - if price_float < 0: - return "Ошибка: Цена не может быть отрицательной", 400 + price = float(price_str) + if price < 0: raise ValueError("Price cannot be negative") except ValueError: - return "Ошибка: Неверный формат цены", 400 + return "Ошибка: Неверный формат цены (должно быть число, например 150 или 150.50)", 400 - if category not in categories and category != 'Без категории': - logging.warning(f"Category '{category}' not found, assigning 'Без категории'.") - category = 'Без категории' - - - # Handle photos - uploaded_photo_filenames = [] - if photos_files and any(f.filename for f in photos_files): - for photo in photos_files[:10]: # Limit uploads + if photos_files: + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + for photo in photos_files[:10]: if photo and photo.filename: original_filename = secure_filename(photo.filename) - # Create a unique filename using timestamp and original name part - timestamp = int(time.time() * 1000) # Milliseconds for better uniqueness - base, ext = os.path.splitext(original_filename) - unique_filename = f"{base}_{timestamp}{ext}" - temp_local_path = os.path.join(UPLOADS_DIR, unique_filename) + # Create a more unique filename + timestamp = int(time.time() * 1000) # Milliseconds + filename_base, filename_ext = os.path.splitext(original_filename) + photo_filename = f"{filename_base}_{timestamp}{filename_ext}" + temp_path = os.path.join(uploads_dir, photo_filename) try: - photo.save(temp_local_path) - logging.info(f"Saved temp photo: {temp_local_path}") - if upload_photo_to_hf(temp_local_path, unique_filename, name): - uploaded_photo_filenames.append(unique_filename) - else: - # Decide if failure to upload one photo should stop the whole process - logging.warning(f"Failed to upload photo {unique_filename} to HF, it won't be added.") - # Don't add to list if upload failed + photo.save(temp_path) + api = HfApi() + logging.info(f"Загрузка фото {photo_filename} на Hugging Face...") + 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"Добавлено фото {photo_filename} для товара {name}" + ) + photos_list.append(photo_filename) + logging.info(f"Фото {photo_filename} успешно загружено.") except Exception as e: - logging.error(f"Error processing photo {original_filename}: {e}") - # Clean up local file if save failed before upload attempt - if os.path.exists(temp_local_path): - try: os.remove(temp_local_path) - except OSError: pass - # Temp file is removed inside upload_photo_to_hf's finally block - - - # Create or Update product - if action == 'add': - new_product = { - 'name': name, - 'price': price_float, - 'description': description, - 'category': category, - 'photos': uploaded_photo_filenames, # Only successfully uploaded photos - 'colors': colors, - 'added_at': datetime.now().isoformat() - } - products.append(new_product) - logging.info(f"Product '{name}' prepared for addition.") - else: # action == 'edit' - product_to_edit = products[index] - product_to_edit['name'] = name - product_to_edit['price'] = price_float - product_to_edit['description'] = description - product_to_edit['category'] = category - product_to_edit['colors'] = colors - # Only replace photos if new ones were successfully uploaded - if uploaded_photo_filenames: - # Optional: Delete old photos from HF (complex, requires tracking old filenames) - logging.info(f"Replacing photos for product '{name}' with: {uploaded_photo_filenames}") - product_to_edit['photos'] = uploaded_photo_filenames - # Keep original 'added_at' timestamp - logging.info(f"Product '{name}' (index {index}) prepared for update.") + logging.error(f"Ошибка при загрузке фото {photo_filename}: {e}") + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError as e: + logging.error(f"Ошибка при удалении временного файла {temp_path}: {e}") + + new_product = { + 'name': name, + 'price': price, + 'description': description, + 'category': category if category in categories or category == 'Без категории' else 'Без категории', + 'photos': photos_list, + 'colors': colors, + 'added_at': datetime.now().isoformat() + } + products.append(new_product) + save_data(data) + logging.info(f"Товар '{name}' добавлен.") + return redirect(url_for('admin')) + elif action == 'edit': + index_str = request.form.get('index') + index = int(index_str) - try: - save_data(data) - logging.info(f"Data saved successfully after {action}.") - except Exception as e: - logging.error(f"Failed to save data after {action}: {e}") - return f"Ошибка сохранения данных при {'добавлении' if action == 'add' else 'редактировании'} товара", 500 + if not 0 <= index < len(products): + return "Ошибка: Неверный индекс товара", 400 - return redirect(url_for('admin')) + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '').replace(',', '.') + description = request.form.get('description', '').strip() + category = request.form.get('category', 'Без категории') + photos_files = request.files.getlist('photos') + colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] - except ValueError: - return "Ошибка: Неверный формат числовых данных (индекс или цена)", 400 - except Exception as e: - logging.error(f"Unexpected error during {action}: {e}") - return "Произошла непредвиденная ошибка", 500 + if not name or not price_str or not description: + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание)", 400 + try: + price_float = float(price_str) + if price_float < 0: raise ValueError("Price cannot be negative") + except ValueError: + return "Ошибка: Неверный формат цены (должно быть число, например 150 или 150.50)", 400 - elif action == 'delete': - try: - index = int(request.form.get('index')) - if isinstance(products, list) and 0 <= index < len(products): - deleted_product = products.pop(index) - product_name = deleted_product.get('name', f'index {index}') - logging.info(f"Product '{product_name}' removed from list.") - # Optional: Delete associated photos from HF (requires listing files in photos/ and matching) - # This is complex and potentially slow, skipping for now. Photos will become orphaned. - try: - save_data(data) - logging.info(f"Data saved after deleting product '{product_name}'.") - except Exception as e: - logging.error(f"Failed to save data after deleting product: {e}") - # Attempt to revert deletion in memory? Complex. - return "Ошибка сохранения данных при удалении товара", 500 - return redirect(url_for('admin')) + new_photos_list = [] + photo_uploaded = False + if photos_files and any(f.filename for f in photos_files): + photo_uploaded = True + uploads_dir = 'uploads' + os.makedirs(uploads_dir, exist_ok=True) + for photo in photos_files[:10]: + if photo and photo.filename: + original_filename = secure_filename(photo.filename) + timestamp = int(time.time() * 1000) + filename_base, filename_ext = os.path.splitext(original_filename) + photo_filename = f"{filename_base}_{timestamp}{filename_ext}" + temp_path = os.path.join(uploads_dir, photo_filename) + try: + photo.save(temp_path) + api = HfApi() + logging.info(f"Загрузка нового фото {photo_filename} для редактируемого товара {name}...") + 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"Обновлено фото {photo_filename} для товара {name}" + ) + new_photos_list.append(photo_filename) + logging.info(f"Фото {photo_filename} успешно заг��ужено.") + except Exception as e: + logging.error(f"Ошибка при загрузке фото {photo_filename} при редактировании: {e}") + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError as e: + logging.error(f"Ошибка при удалении временного файла {temp_path} при редактировании: {e}") + # Consider deleting old photos from HF here (complex, skipped for now) + + products[index]['name'] = name + products[index]['price'] = price_float + products[index]['description'] = description + products[index]['category'] = category if category in categories or category == 'Без категории' else 'Без категории' + products[index]['colors'] = colors + if photo_uploaded: + products[index]['photos'] = new_photos_list # Replace photos only if new ones were uploaded + + save_data(data) + logging.info(f"Товар '{name}' (индекс {index}) обновлен.") + return redirect(url_for('admin')) + + elif action == 'delete': + index_str = request.form.get('index') + index = int(index_str) + if 0 <= index < len(products): + deleted_product_name = products[index].get('name', 'Неизвестный товар') + # Consider deleting photos from HF here (complex, skipped for now) + del products[index] + save_data(data) + logging.info(f"Товар '{deleted_product_name}' (индекс {index}) удален.") else: - return "Ошибка: Неверный индекс товара для удаления", 400 - except (ValueError, TypeError): - return "Ошибка: Неверный индекс товара", 400 - except Exception as e: - logging.error(f"Ошибка при удалении товара: {e}") - return "Ошибка при удалении товара", 500 + return "Ошибка: Неверный индекс товара", 400 + return redirect(url_for('admin')) + + except ValueError: + return "Ошибка: Неверный индекс", 400 + except Exception as e: + logging.error(f"Ошибка при обработке действия '{action}': {e}") + return f"Произошла внутренняя ошибка: {e}", 500 - # --- Render Admin Page (GET request or after POST redirect) --- admin_html = ''' @@ -896,16 +818,15 @@ def admin(): Админ-панель - +
- -

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

+ +

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

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} -

Добавить новый товар

-
+ - + @@ -1002,18 +912,18 @@ def admin(): - - + +
- +
@@ -1022,7 +932,7 @@ def admin():

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

-
+ @@ -1033,8 +943,8 @@ def admin():
{% for category in categories %}
-

{{ category }}

- +

{{ category|e }}

+ @@ -1047,67 +957,67 @@ def admin():

Управление базой данных

- + - Скачать актуальную базу +
+ +
-

Список товаров

+

Список товаров ({{ products|length }})

{% for product in products %}
-

{{ product.get('name', 'N/A') }}

-

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

-

Цена: {{ product.get('price', 0)|float }} с

-

Описание: {{ product.get('description', '') }}

-

Цвета: {{ product.get('colors', [])|join(', ') if product.get('colors') else 'Нет цветов' }}

-

Добавлено: {{ product.get('added_at', 'N/A') }}

- {% set photos = product.get('photos', []) %} - {% if photos %} +

{{ product['name']|e }}

+

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

+

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

+

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

+

Цвета: {{ (product.get('colors', [])|map('escape')|join(', ')) if product.get('colors') else 'Нет цветов' }}

+ {% if product.get('photos') %}
- {% for photo in photos %} - Фото {{ product.get('name', 'N/A') }} {% endfor %}
{% endif %}
-
+
Редактировать -
+ - + - + - + - - + +
{% set colors = product.get('colors', []) %} {% if colors %} {% for color in colors %}
- - + +
{% endfor %} {% else %}
- +
{% endif %}
@@ -1115,7 +1025,7 @@ def admin():
-
+ @@ -1135,112 +1045,114 @@ def admin(): newInputGroup.className = 'color-input-group'; newInputGroup.innerHTML = \` - + \`; container.querySelectorAll('.remove-color-btn').forEach(btn => btn.style.display = 'inline-block'); container.appendChild(newInputGroup); - // Focus the new input - newInputGroup.querySelector('input[name="colors"]').focus(); + newInputGroup.querySelector('input').focus(); // Focus new input } function removeColorInput(button) { + const group = button.closest('.color-input-group'); const container = button.closest('.color-input-container'); - if (!container) return; - const groupToRemove = button.closest('.color-input-group'); - if (groupToRemove) { - groupToRemove.remove(); - } + if (!group || !container) return; + group.remove(); const remainingGroups = container.querySelectorAll('.color-input-group'); if (remainingGroups.length === 1) { const lastRemoveBtn = remainingGroups[0].querySelector('.remove-color-btn'); if(lastRemoveBtn) lastRemoveBtn.style.display = 'none'; } else if (remainingGroups.length === 0) { - // Optionally add a new empty one if all are removed, or leave it empty - // For simplicity, let's allow it to be empty. User can add one if needed. - // addColorInput(container.id); // Uncomment to always have at least one + // Optionally add a new empty one if all are removed + addColorInput(container.id); + // Ensure the new one's remove button is hidden + const newGroup = container.querySelector('.color-input-group'); + if (newGroup) { + const newRemoveBtn = newGroup.querySelector('.remove-color-btn'); + if (newRemoveBtn) newRemoveBtn.style.display = 'none'; + } } } - // Initialize remove button visibility on page load - document.querySelectorAll('.color-input-container').forEach(container => { - const groups = container.querySelectorAll('.color-input-group'); - if (groups.length === 1) { - const removeBtn = groups[0].querySelector('.remove-color-btn'); - if (removeBtn) removeBtn.style.display = 'none'; - } else if (groups.length > 1) { - groups.forEach(group => { - const removeBtn = group.querySelector('.remove-color-btn'); - if (removeBtn) removeBtn.style.display = 'inline-block'; - }) - } - }); - - // Add listener to close details when another opens (optional QoL) - document.querySelectorAll('.product-item details').forEach(detailsElement => { - detailsElement.addEventListener('toggle', event => { - if (detailsElement.open) { - document.querySelectorAll('.product-item details').forEach(otherDetails => { - if (otherDetails !== detailsElement && otherDetails.open) { - otherDetails.removeAttribute('open'); - } - }); - } - }); + document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.color-input-container').forEach(container => { + const groups = container.querySelectorAll('.color-input-group'); + if (groups.length === 1) { + const removeBtn = groups[0].querySelector('.remove-color-btn'); + if (removeBtn) removeBtn.style.display = 'none'; + } else if (groups.length === 0) { + // Add initial input if none exists (e.g., for 'add' form) + addColorInput(container.id); + const newGroup = container.querySelector('.color-input-group'); + if (newGroup) { + const newRemoveBtn = newGroup.querySelector('.remove-color-btn'); + if (newRemoveBtn) newRemoveBtn.style.display = 'none'; + } + } + }); }); ''' - # Sort products for display in admin panel as well - products_sorted_admin = sorted( - products, - key=lambda x: x.get('added_at', '1970-01-01T00:00:00'), - reverse=True - ) - return render_template_string(admin_html, products=products_sorted_admin, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + # Sort products by name for admin display + products_sorted = sorted(products, key=lambda x: x.get('name', '').lower()) + return render_template_string(admin_html, products=products_sorted, categories=sorted(categories), repo_id=REPO_ID) + @app.route('/backup', methods=['POST']) def backup(): - logging.info("Manual backup requested.") - if upload_db_to_hf(): - # Optionally add flash message: flash("Резервная копия успешно создана.", "success") - return "Резервная копия успешно создана и загружена на Hugging Face.", 200 - else: - # Optionally add flash message: flash("Ошибка при создании резервной копии.", "error") - return "Ошибка при создании/загрузке резервной копии. Проверьте логи.", 500 + logging.info("Запрос на ручное создание резервной копии...") + with data_lock: + try: + if os.path.exists(DATA_FILE): + upload_db_to_hf_internal() + logging.info("Ручная резервная копия успешно создана.") + # TODO: Add flash message for user feedback + return redirect(url_for('admin')) # Redirect back to admin page + else: + logging.error("Ошибка ручного бэкапа: Локальный файл data.json не найден.") + return "Ошибка: Локальный файл data.json не найден для создания резервной копии.", 404 + except Exception as e: + logging.error(f"Ошибка при ручном создании резервной копии: {e}") + return f"Ошибка при создании резервной копии: {e}", 500 @app.route('/download', methods=['GET']) def download(): - logging.info("Manual database download requested.") - if download_db_from_hf(): - # Optionally add flash message: flash("Актуальная база данных успешно скачана.", "success") - # Redirect back to admin or show success message - # return redirect(url_for('admin')) - return "Актуальная база данных успешно скачана из Hugging Face. Обновите страницу админ-панели.", 200 - else: - # Optionally add flash message: flash("Ошибка при скачивании базы данных.", "error") - return "Ошибка при скачивании базы данных из Hugging Face. Проверьте логи.", 500 + logging.info("Запрос на ручное скачивание базы данных...") + try: + with data_lock: + download_db_from_hf() + logging.info("Ручное скачивание базы данных успешно завершено.") + # TODO: Add flash message for user feedback + return redirect(url_for('admin')) # Redirect back to admin page + except RepositoryNotFoundError: + logging.error("Ошибка ручного скачивания: Репозиторий Hugging Face не найден.") + return "Ошибка: Репозиторий Hugging Face не найден.", 404 + except Exception as e: + logging.error(f"Ошибка при ручном скачивании базы данных: {e}") + return f"Ошибка при скачивании базы данных: {e}", 500 if __name__ == '__main__': - # Initial data load attempt on startup - logging.info("Application starting up...") - load_data() - - # Start background backup thread only if HF token is available - if HF_TOKEN_WRITE: - backup_thread = threading.Thread(target=periodic_backup, daemon=True) - backup_thread.start() - logging.info("Periodic backup thread started.") - else: - logging.warning("HF_TOKEN_WRITE not set. Periodic backup disabled.") + uploads_dir = 'uploads' + if not os.path.exists(uploads_dir): + os.makedirs(uploads_dir) + logging.info(f"Создана директория {uploads_dir}") + + # Initial data load attempt + logging.info("Первоначальная загрузка данных...") + load_data() # Load data on startup + + # Start background backup thread + logging.info("Запуск потока периодического резервного копирования...") + backup_thread = threading.Thread(target=periodic_backup, daemon=True) + backup_thread.start() - # Run the Flask app port = int(os.environ.get("PORT", 7860)) - logging.info(f"Starting Flask app on host 0.0.0.0, port {port}") - # Use 'waitress' for a production-ready server if not using Gunicorn/Docker + logging.info(f"Запуск Flask приложения на host 0.0.0.0, port {port}") + # Use Waitress or Gunicorn for production # from waitress import serve # serve(app, host='0.0.0.0', port=port) app.run(debug=False, host='0.0.0.0', port=port)