diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,6 @@ -from flask import Flask, render_template_string, request, redirect, url_for + + +from flask import Flask, render_template_string, request, redirect, url_for, send_file, Response import json import os import logging @@ -8,53 +10,96 @@ from datetime import datetime from huggingface_hub import HfApi, hf_hub_download from huggingface_hub.utils import RepositoryNotFoundError from werkzeug.utils import secure_filename +import zipfile +import io +import tempfile app = Flask(__name__) DATA_FILE = 'dataasdem.json' +# Убедитесь, что REPO_ID указан в формате owner/repo_name REPO_ID = "flpolprojects/Clients" -HF_TOKEN_WRITE = os.getenv("HF_TOKEN") -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") +HF_TOKEN_WRITE = os.getenv("HF_TOKEN") # Токен для записи (нужен для загрузки DB и фото) +HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Токен для чтения (нужен для скачивания DB и фото) + +# Если HF_TOKEN_READ не установлен, используем HF_TOKEN_WRITE для чтения +if not HF_TOKEN_READ and HF_TOKEN_WRITE: + HF_TOKEN_READ = HF_TOKEN_WRITE +elif not HF_TOKEN_READ and not HF_TOKEN_WRITE: + logging.error("Hugging Face токены HF_TOKEN или HF_TOKEN_READ не установлены.") + # Приложение может работать локально, но загрузка/скачивание с HF не будет работать. LOGO_URL = "https://cdn-avatars.huggingface.co/v1/production/uploads/67b22aaeae9b6a59f1cfb849/NQvBksXzJItYt6hfFjyaB.jpeg" logging.basicConfig(level=logging.DEBUG) def load_data(): + """Загружает данные из локального JSON файла, предварительно скачав его с HF.""" + logging.info("Попытка загрузки данных.") try: download_db_from_hf() 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: - return {'products': [], 'categories': [] if not isinstance(data, list) else data} + logging.info("Данные успешно загружены из JSON.") + # Проверка структуры данных, чтобы избежать ошибок NoneType + if not isinstance(data, dict): + logging.warning("Файл JSON имеет некорректную структуру (не словарь верхнего уровня).") + return {'products': [], 'categories': []} + if 'products' not in data or not isinstance(data['products'], list): + logging.warning("В JSON отсутствует ключ 'products' или он не является списком.") + data['products'] = [] + if 'categories' not in data or not isinstance(data['categories'], list): + logging.warning("В JSON отсутствует ключ 'categories' или он не является списком.") + data['categories'] = [] return data except FileNotFoundError: - logging.warning("Локальный файл базы данных не найден после скачивания.") + logging.warning(f"Локальный файл базы данных '{DATA_FILE}' не найден после попытки скачивания.") return {'products': [], 'categories': []} except json.JSONDecodeError: - logging.error("Ошибка: Невозможно декодировать JSON файл.") + logging.error(f"Ошибка: Невозможно декодировать JSON файл '{DATA_FILE}'. Файл может быть поврежден.") return {'products': [], 'categories': []} except RepositoryNotFoundError: - logging.error("Репозиторий не найден. Создание локальной базы данных.") - return {'products': [], 'categories': []} + logging.error(f"Репозиторий '{REPO_ID}' не найден на Hugging Face. Создание локальной пустой базы данных.") + # Создаем пустую локальную базу данных, если репозиторий не существует + initial_data = {'products': [], 'categories': []} + try: + with open(DATA_FILE, 'w', encoding='utf-8') as file: + json.dump(initial_data, file, ensure_ascii=False, indent=4) + logging.info(f"Создан пустой локальный файл базы данных '{DATA_FILE}'.") + except Exception as save_err: + logging.error(f"Ошибка при создании пустого локального файла базы данных: {save_err}") + return initial_data except Exception as e: logging.error(f"Произошла ошибка при загрузке данных: {e}") return {'products': [], 'categories': []} + def save_data(data): + """Сохраняет данные в локальный JSON файл и загружает на HF.""" + logging.info("Попытка сохранения данных.") try: + # Сортируем продукты по времени добавления перед сохранением + # Это поможет сохранить порядок добавления, хотя список в памяти сортируется отдельно для отображения + if 'products' in data: + data['products'].sort(key=lambda x: x.get('added_at', ''), reverse=True) + with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) - logging.info("Данные успешно сохранены в JSON") + logging.info(f"Данные успешно сохранены в локальный JSON файл '{DATA_FILE}'.") upload_db_to_hf() except Exception as e: logging.error(f"Ошибка при сохранении данных: {e}") + # В случае ошибки сохранения, лучше пробросить исключение, чтобы админ получил обратную связь raise def upload_db_to_hf(): + """Загружает локальный JSON файл на Hugging Face Hub.""" + if not HF_TOKEN_WRITE: + logging.warning("HF_TOKEN_WRITE не установлен. Пропуск загрузки базы данных на Hugging Face.") + return try: api = HfApi() + logging.info(f"Загрузка файла '{DATA_FILE}' в репозиторий '{REPO_ID}' на Hugging Face.") api.upload_file( path_or_fileobj=DATA_FILE, path_in_repo=DATA_FILE, @@ -65,37 +110,173 @@ def upload_db_to_hf(): ) logging.info("Резервная копия JSON базы успешно загружена на Hugging Face.") except Exception as e: - logging.error(f"Ошибка при загрузке резервной копии: {e}") + logging.error(f"Ошибка при загрузке резервной копии '{DATA_FILE}' на Hugging Face: {e}") def download_db_from_hf(): + """Скачивает JSON файл базы данных с Hugging Face Hub.""" + if not HF_TOKEN_READ: + logging.warning("HF_TOKEN_READ не установлен. Пропуск скачивания базы данных с Hugging Face.") + # Если токена нет, FileNotFoundError в load_data обработает отсутствие локального файла + raise FileNotFoundError(f"Не удалось скачать базу данных '{DATA_FILE}' из-за отсутствия HF_TOKEN_READ.") + try: + logging.info(f"Скачивание файла '{DATA_FILE}' из репозитория '{REPO_ID}' на Hugging Face.") hf_hub_download( repo_id=REPO_ID, filename=DATA_FILE, repo_type="dataset", token=HF_TOKEN_READ, - local_dir=".", - local_dir_use_symlinks=False + local_dir=".", # Скачиваем прямо в корневую директорию приложения + local_dir_use_symlinks=False # Важно для надежности в разных окружениях ) - logging.info("JSON база успешно скачана из Hugging Face.") + logging.info(f"JSON база '{DATA_FILE}' успешно скачана из Hugging Face.") except RepositoryNotFoundError as e: - logging.error(f"Репозиторий не найден: {e}") - raise + logging.error(f"Репозиторий '{REPO_ID}' не найден. Невозможно скачать базу данных.") + raise # Пробрасываем ошибку, чтобы load_data мог ее обработать except Exception as e: - logging.error(f"Ошибка при скачивании JSON базы: {e}") + logging.error(f"Ошибка при скачивании JSON базы '{DATA_FILE}' из Hugging Face: {e}") + # Пробрасываем ошибку, чтобы load_data мог ее обработать raise def periodic_backup(): + """Выполняет периодическое резервное копирование.""" + # Добавляем небольшую задержку при старте, чтобы приложение успело инициализироваться + time.sleep(60) # Задержка в 60 секунд перед первым бэкапом while True: - upload_db_to_hf() + try: + logging.info("Запуск периодического резервного копирования.") + # Нет необходимости загружать данные, backup загружает локальный файл + upload_db_to_hf() + except Exception as e: + logging.error(f"Ошибка во время периодического бэкапа: {e}") + # Ждем 800 секунд (приблизительно 13.3 минуты) time.sleep(800) +# --- Новая функция для загрузки фотографий и создания ZIP --- +@app.route('/download_photos') +def download_photos(): + """ + Скачивает все фотографии, указанные в базе данных, из репозитория HF Hub + и упаковывает их в ZIP-архив для скачивания. + """ + logging.info("Получен запрос на скачивание всех фотографий.") + data = load_data() + all_photo_filenames = set() # Используем set для уникальных имен файлов + + # Собираем все уникальные имена файлов фотографий из продуктов + if 'products' in data and isinstance(data['products'], list): + for product in data['products']: + if 'photos' in product and isinstance(product['photos'], list): + for photo_filename in product['photos']: + if photo_filename and isinstance(photo_filename, str): + # Удаляем возможные пробелы или другие нежелательные символы + cleaned_filename = photo_filename.strip() + if cleaned_filename: + all_photo_filenames.add(cleaned_filename) + + logging.info(f"Найдено {len(all_photo_filenames)} уникальных файлов фотографий в базе данных.") + + if not all_photo_filenames: + logging.warning("В базе данных нет фотографий для скачивания. Создаю пустой ZIP.") + # Если нет фотографий, создаем пустой ZIP с информационным файлом + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, 'w') as zipf: + zipf.writestr('info.txt', 'В базе данных не найдено фотографий для скачивания.') + buffer.seek(0) + return send_file(buffer, mimetype='application/zip', as_attachment=True, download_name='product_photos_empty.zip') + + # Используем BytesIO для создания ZIP архива в памяти + buffer = io.BytesIO() + + # Используем TemporaryDirectory для безопасного скачивания файлов во временное место + # Это автоматически удалит директорию и ее содержимое после выхода из блока with + try: + with tempfile.TemporaryDirectory() as temp_dir: + logging.info(f"Создана временная директория для скачивания: {temp_dir}") + downloaded_files_paths = [] + + # Скачиваем каждый файл фотографии + for filename in all_photo_filenames: + remote_path = f"photos/{filename}" + local_path = os.path.join(temp_dir, filename) # Сохраняем во временной директории + + # Проверяем, существует ли файл уже во временной директории (на всякий случай) + if os.path.exists(local_path): + logging.debug(f"Файл уже существует во временной директории, пропуск скачивания: {local_path}") + downloaded_files_paths.append(local_path) + continue # Пропускаем скачивание, если файл уже есть + + logging.info(f"Попытка скачивания файла: {remote_path}") + try: + # Скачиваем файл из HF Hub в нашу временную директорию + # cache_dir=None предотвращает кэширование во временной директории + downloaded_file_path = hf_hub_download( + repo_id=REPO_ID, + filename=remote_path, + repo_type="dataset", + token=HF_TOKEN_READ, + local_dir=temp_dir, + local_dir_use_symlinks=False, + cache_dir=None # Не используем кэш HF Hub для временных файлов + ) + # Проверяем, что hf_hub_download вернул путь и файл существует + if downloaded_file_path and os.path.exists(downloaded_file_path): + downloaded_files_paths.append(downloaded_file_path) + logging.info(f"Файл успешно скачан и добавлен в список для архивации: {downloaded_file_path}") + else: + logging.warning(f"Скачивание файла {remote_path} не удалось или файл не найден по пути: {downloaded_file_path}") + + except Exception as e: + # Логируем ошибку для этого файла, но продолжаем скачивать остальные + logging.error(f"Ошибка скачивания файла {remote_path} из Hugging Face: {e}") + # Файл не будет добавлен в downloaded_files_paths, если произошла ошибка + + if not downloaded_files_paths: + logging.error("После попыток скачивания ни одного файла не было успешно загружено.") + return "Ошибка: Не удалось скачать ни одной фотографии.", 500 # Возвращаем ошибку, если ни один файл не скачался + + # Создаем ZIP архив из скачанных файлов + logging.info(f"Начинаю создание ZIP архива из {len(downloaded_files_paths)} скачанных файлов.") + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zipf: + for local_path in downloaded_files_paths: + # Добавляем файл в архив, используя только его базовое имя + arcname = os.path.basename(local_path) + try: + zipf.write(local_path, arcname=arcname) + logging.debug(f"Добавлен файл в ZIP: {arcname}") + except Exception as e: + logging.error(f"Ошибка при добавлении файла {local_path} в ZIP архив: {e}") + # Можно продолжить, если ошибка только с одним файлом + + # Перемещаем указатель буфера в начало + buffer.seek(0) + + logging.info("ZIP архив успешно создан в памяти. Отправляю файл пользователю.") + + # Отправляем ZIP файл пользователю + return send_file( + buffer, + mimetype='application/zip', + as_attachment=True, + download_name='product_photos.zip' # Имя файла, которое увидит пользователь при скачивании + ) + + except Exception as e: + # Общая ошибка при работе с временной директорией или zip + logging.error(f"Произошла общая ошибка при создании или скачивании ZIP архива: {e}") + return f"Произошла ошибка при обработке фотографий: {e}", 500 + +# --- Конец новой функции --- + + @app.route('/') def catalog(): data = load_data() - products = sorted(data['products'], key=lambda x: x.get('added_at', ''), reverse=True) - categories = data['categories'] - + # Сортируем продукты по дате добавления для отображения новых сверху + products = sorted(data.get('products', []), key=lambda x: x.get('added_at', ''), reverse=True) + categories = data.get('categories', []) + + # HTML шаблон для каталога - остался прежним, кроме добавления LOGO_URL в header-logo catalog_html = ''' @@ -202,7 +383,7 @@ def catalog(): } .products-grid { display: grid; - grid-template-columns: repeat(2, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); # Используем auto-fit для лучшей адаптивности gap: 15px; padding: 10px; } @@ -213,6 +394,8 @@ def catalog(): box-shadow: 0 4px 15px var(--shadow-color); transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease; overflow: hidden; + display: flex; # Используем flexbox для лучшего выравнивания содержимого + flex-direction: column; } .product:hover { transform: translateY(-5px) scale(1.02); @@ -227,6 +410,7 @@ def catalog(): display: flex; justify-content: center; align-items: center; + flex-shrink: 0; # Изображение не сжимается } .product-image img { max-width: 100%; @@ -245,6 +429,7 @@ def catalog(): white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex-grow: 0; # Заголовок не растет } .product-price { font-size: 1.1rem; @@ -252,6 +437,7 @@ def catalog(): font-weight: 700; text-align: center; margin: 5px 0; + flex-grow: 0; # Цена не растет } .product-description { font-size: 0.8rem; @@ -261,6 +447,7 @@ def catalog(): overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + flex-grow: 0; # Описание не растет } .product-button { display: block; @@ -277,6 +464,10 @@ def catalog(): margin: 5px 0; text-align: center; text-decoration: none; + flex-grow: 1; # Кнопки могут немного растягиваться, если нужно + display: flex; # Используем flexbox для выравнивания текста внутри кнопки + justify-content: center; + align-items: center; } .product-button:hover { background-color: #E91E63; @@ -306,6 +497,12 @@ def catalog(): box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); z-index: 1000; + display: flex; # Центрируем иконку + justify-content: center; + align-items: center; + } + #cart-button.visible { + display: flex; # Показываем, если есть товары } .modal { display: none; @@ -317,6 +514,7 @@ def catalog(): height: 100%; background-color: rgba(0,0,0,0.5); backdrop-filter: blur(5px); + overflow: auto; # Добавляем прокрутку для модальных окон } .modal-content { background: var(--light-text); @@ -355,15 +553,26 @@ def catalog(): object-fit: contain; border-radius: 8px; margin-right: 15px; + flex-shrink: 0; + } + .cart-item > div { + flex-grow: 1; + margin-right: 10px; # Отступ между информацией и ценой + } + .cart-item span { + flex-shrink: 0; # Цена не сжимается + font-weight: bold; } + .quantity-input, .color-select { width: 100%; - max-width: 150px; - padding: 8px; + max-width: 200px; # Увеличиваем максимальную ширину + padding: 10px; # Увеличиваем отступы border: 1px solid var(--secondary-color); border-radius: 8px; font-size: 1rem; - margin: 5px 0; + margin: 10px 0; # Увеличиваем отступы + display: block; # Каждый элемент на новой строке } .clear-cart { background-color: #ef4444; @@ -374,28 +583,65 @@ def catalog(): } .order-button { background-color: var(--secondary-color); + margin-left: 10px; # Отступ от кнопки очистки } .order-button:hover { background-color: #BA68C8; box-shadow: 0 4px 15px rgba(186, 104, 200, 0.4); } + .button-group { + margin-top: 20px; + text-align: right; + } + .button-group .product-button { + display: inline-block; # Делаем кнопки строчно-блочными + width: auto; # Ширина по содержимому + margin-left: 10px; + } + + @media (max-width: 768px) { + .header { + flex-direction: column; + text-align: center; + } + .header h1 { + margin-left: 0; + margin-top: 10px; + } .products-grid { - grid-template-columns: repeat(2, minmax(150px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); # Меньший размер на мелких экранах } + .modal-content { + margin: 10% auto; # Больше отступ сверху на мелких экранах + } + .cart-item { + flex-direction: column; + align-items: flex-start; + } + .cart-item > div { + margin-bottom: 10px; + margin-right: 0; + } + .button-group { + text-align: center; + } + .button-group .product-button { + margin: 5px; # Отступы между кнопками + } }
- + # Используем переменную logo_url

Каталог

{% for category in categories %} - + # Экранируем категории {% endfor %}
@@ -403,25 +649,34 @@ def catalog():
{% for product in products %} -
+
{% if product.get('photos') and product['photos']|length > 0 %}
- {{ product['name'] }}
+ {% else %} +
+ No Image Available +
{% endif %} -

{{ product['name'] }}

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

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

+

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

# Экранируем имя +
{{ product['price']|e }} с
# Экранируем цену +

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

# Экранируем описание
{% endfor %}
+ {% if not products %} +

Нет товаров в каталоге.

+ {% endif %}
@@ -446,9 +703,9 @@ def catalog(): ×

Корзина

-
+
# Используем новую группу для кнопок Итого: 0 с - +
@@ -456,8 +713,8 @@ def catalog(): - - + {# jQuery не используется, можно удалить #} + {# Popper не используется, можно удалить #} + {# Скрипты jQuery и Popper не используются в вашем текущем коде, их можно безопасно удалить #} + {# #} + {# #} + ''' - return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID) + # Передаем logo_url как отдельную переменную, т.к. она используется напрямую в href + return render_template_string(catalog_html, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + @app.route('/product/') def product_detail(index): data = load_data() - products = data['products'] + products = data.get('products', []) # Используем .get() с дефолтом [] try: product = products[index] - except IndexError: + except (IndexError, TypeError): # Добавляем TypeError на случай, если products не список + logging.error(f"Попытка доступа к несуществующему индексу продукта: {index}") return "Продукт не найден", 404 + + # HTML шаблон для деталей продукта detail_html = '''
-

{{ product['name'] }}

+

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

{# Экранируем имя #}
- {% if product.get('photos') %} + {% if product.get('photos') and product['photos']|length > 0 %} {% for photo in product['photos'] %}
- {{ product['name'] }}
{% endfor %} {% else %} -
- No Image +
+
+ No Image Available +
{% endif %}
@@ -667,133 +1011,259 @@ def product_detail(index):
-

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

-

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

-

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

-

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

+

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

{# Экранируем категорию #} +

Цена: {{ product.get('price', 'N/A')|e }} с

{# Экранируем цену, добавляем дефолт #} +

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

{# Экранируем описание, добавляем дефолт #} +

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

{# Экранируем цвета, добавляем дефолт #}
''' return render_template_string(detail_html, product=product, repo_id=REPO_ID) + @app.route('/admin', methods=['GET', 'POST']) def admin(): data = load_data() - products = data['products'] - categories = data['categories'] + products = data.get('products', []) + categories = data.get('categories', []) + + # Убедимся, что категории уникальны и сортированы (необязательно, но полезно) + categories = sorted(list(set(c for c in categories if isinstance(c, str) and c.strip()))) + # Обновляем данные после возможной очистки категорий + data['categories'] = categories + if request.method == 'POST': action = request.form.get('action') - + logging.debug(f"Админ: Получено действие POST - {action}") + if action == 'add_category': category_name = request.form.get('category_name') - if category_name and category_name not in categories: - categories.append(category_name) - save_data(data) + if category_name and isinstance(category_name, str) and category_name.strip(): + cleaned_name = category_name.strip() + if cleaned_name not in categories: + categories.append(cleaned_name) + categories.sort() # Сортируем после добавления + data['categories'] = categories # Обновляем в словаре data + save_data(data) + logging.info(f"Админ: Добавлена категория '{cleaned_name}'.") + else: + logging.warning(f"Админ: Попытка добавить существующую категорию '{cleaned_name}'.") + # Всегда перенаправляем после POST запроса return redirect(url_for('admin')) - return "Ошибка: Категория уже существует или не указано название", 400 + else: + logging.warning("Админ: Попытка добавить пустую или некорректную категорию.") + return "Ошибка: Не указано название категории или оно некорректно.", 400 elif action == 'delete_category': - category_index = int(request.form.get('category_index')) - deleted_category = categories.pop(category_index) - for product in products: - if product.get('category') == deleted_category: - product['category'] = 'Без категории' - save_data(data) - return redirect(url_for('admin')) + try: + category_index = int(request.form.get('category_index')) + if 0 <= category_index < len(categories): + deleted_category = categories.pop(category_index) + # Обновляем в словаре data перед сохранением + data['categories'] = categories + # Проходим по продуктам и меняем удаленную категорию на "Без категории" + for product in products: + if product.get('category') == deleted_category: + product['category'] = 'Без категории' + save_data(data) + logging.info(f"Админ: Удалена категория '{deleted_category}'.") + return redirect(url_for('admin')) + else: + logging.warning(f"Админ: Попытка удалить категорию по некорректному индексу {category_index}.") + return "Ошибка: Некорректный индекс категории.", 400 + except (ValueError, TypeError): + logging.error("Админ: Получен некорректный индекс категории для удаления.") + return "Ошибка: Некорректный индекс категории.", 400 + elif action == 'add': - name = request.form.get('name') - price = request.form.get('price') - description = request.form.get('description') - category = request.form.get('category') + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category', 'Без категории').strip() photos_files = request.files.getlist('photos') - colors = request.form.getlist('colors') + colors_list = request.form.getlist('colors') # Получаем список цветов + + if not name or not price_str or not description: + logging.warning("Админ: Попытка добавить товар с незаполненными обязательными полями.") + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание).", 400 + + try: + # Заменяем запятую на точку для корректного преобразования в float + price = float(price_str.replace(',', '.')) + if price < 0: + raise ValueError("Цена не может быть отрицательной") + except (ValueError, TypeError): + logging.warning(f"Админ: Попытка добавить товар с некорректной ценой: '{price_str}'.") + return "Ошибка: Некорректное значение цены.", 400 + photos_list = [] - if photos_files: - for photo in photos_files[:10]: + logging.info(f"Админ: Получено {len(photos_files)} файлов для загрузки.") + api = HfApi() + uploads_dir = 'uploads' # Временная директория для сохранения перед загрузкой на HF + os.makedirs(uploads_dir, exist_ok=True) # Убедимся, что директория существует + + for photo in photos_files[:10]: # Ограничиваем до 10 файлов if photo and photo.filename: - photo_filename = secure_filename(photo.filename) - uploads_dir = 'uploads' - os.makedirs(uploads_dir, exist_ok=True) + original_filename = secure_filename(photo.filename) + # Добавляем timestamp к имени файла, чтобы избежать коллизий + timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f") + photo_filename = f"{timestamp}_{original_filename}" temp_path = os.path.join(uploads_dir, photo_filename) - photo.save(temp_path) - api = HfApi() - 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) - if os.path.exists(temp_path): - os.remove(temp_path) - - if not name or not price or not description: - return "Ошибка: Заполните все обязательные поля", 400 - - price = float(price.replace(',', '.')) + + try: + photo.save(temp_path) + logging.debug(f"Админ: Файл сохранен локально: {temp_path}") + + # Загружаем файл на Hugging Face + 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}'" + ) + logging.info(f"Админ: Фото '{photo_filename}' успешно загружено на Hugging Face.") + photos_list.append(photo_filename) # Добавляем имя файла в список для JSON + + except Exception as e: + logging.error(f"Админ: Ошибка загрузки фото '{original_filename}' на Hugging Face: {e}") + # Продолжаем, даже если один файл не загрузился + + finally: + # Удаляем временный файл после попытки загрузки + if os.path.exists(temp_path): + os.remove(temp_path) + logging.debug(f"Админ: Временный файл удален: {temp_path}") + + # Очищаем и фильтруем список цветов + cleaned_colors = [color.strip() for color in colors_list if color and color.strip()] + new_product = { 'name': name, 'price': price, 'description': description, + # Проверяем, существует ли выбранная категория, иначе "Без категории" 'category': category if category in categories else 'Без категории', 'photos': photos_list, - 'colors': colors if colors else [], - 'added_at': datetime.now().isoformat() + 'colors': cleaned_colors, + 'added_at': datetime.now().isoformat() # Добавляем метку времени } products.append(new_product) - save_data(data) - return redirect(url_for('admin')) - + save_data(data) # Сохраняем обновленные данные + logging.info(f"Админ: Товар '{name}' успешно добавлен.") + return redirect(url_for('admin')) # Перенаправляем на ту же страницу после добавления + elif action == 'edit': - index = int(request.form.get('index')) - name = request.form.get('name') - price = request.form.get('price') - description = request.form.get('description') - category = request.form.get('category') - photos_files = request.files.getlist('photos') - colors = request.form.getlist('colors') - - if photos_files and any(photo.filename for photo in photos_files): - new_photos_list = [] - for photo in photos_files[:10]: - if photo and photo.filename: - photo_filename = secure_filename(photo.filename) - uploads_dir = 'uploads' - os.makedirs(uploads_dir, exist_ok=True) - temp_path = os.path.join(uploads_dir, photo_filename) - photo.save(temp_path) - api = HfApi() - 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}" - ) - new_photos_list.append(photo_filename) - if os.path.exists(temp_path): - os.remove(temp_path) - products[index]['photos'] = new_photos_list - - products[index]['name'] = name - products[index]['price'] = float(price.replace(',', '.')) - products[index]['description'] = description - products[index]['category'] = category if category in categories else 'Без категории' - products[index]['colors'] = colors if colors else [] - save_data(data) - return redirect(url_for('admin')) - + try: + index = int(request.form.get('index')) + if not (0 <= index < len(products)): + logging.warning(f"Админ: Попытка редактирования продукта по некорректному индексу {index}.") + return "Ошибка: Некорректный индекс продукта.", 400 + + product = products[index] # Получаем продукт по индексу + + name = request.form.get('name', '').strip() + price_str = request.form.get('price', '').strip() + description = request.form.get('description', '').strip() + category = request.form.get('category', 'Без категории').strip() + photos_files = request.files.getlist('photos') + colors_list = request.form.getlist('colors') + + if not name or not price_str or not description: + logging.warning(f"Админ: Попытка редактирования товара {index} с незаполненными обязательными полями.") + return "Ошибка: Заполните все обязательные поля (Название, Цена, Описание).", 400 + + try: + price = float(price_str.replace(',', '.')) + if price < 0: + raise ValueError("Цена не может быть отрицательной") + except (ValueError, TypeError): + logging.warning(f"Админ: Попытка редактирования товара {index} с некорректной ценой: '{price_str}'.") + return "Ошибка: Некорректное значение цены.", 400 + + # Обновляем текстовые поля и категорию + product['name'] = name + product['price'] = price + product['description'] = description + product['category'] = category if category in categories else 'Без категории' + + # Обновляем цвета + product['colors'] = [color.strip() for color in colors_list if color and color.strip()] + + # Обработка новых фотографий (заменяем существующие, если новые загружены) + if photos_files and any(photo.filename for photo in photos_files): + logging.info(f"Админ: Получено {len(photos_files)} новых файлов для товара {index} для загрузки.") + new_photos_list = [] + api = HfApi() + 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 = datetime.now().strftime("%Y%m%d%H%M%S%f") + photo_filename = f"{timestamp}_{original_filename}" + temp_path = os.path.join(uploads_dir, photo_filename) + + try: + photo.save(temp_path) + logging.debug(f"Админ: Файл сохранен локально: {temp_path}") + 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}' (индекс {index})" + ) + logging.info(f"Админ: Новое фото '{photo_filename}' успешно загружено.") + new_photos_list.append(photo_filename) # Добавляем новое имя в список + except Exception as e: + logging.error(f"Админ: Ошибка загрузки нового фото '{original_filename}' для товара {index}: {e}") + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + logging.debug(f"Админ: Временный файл удален: {temp_path}") + + # Заменяем старый список фото на новый + product['photos'] = new_photos_list + logging.info(f"Админ: Список фото для товара {index} обновлен.") + + save_data(data) # Сохраняем обновленные данные + logging.info(f"Админ: Товар '{name}' (индекс {index}) успешно обновлен.") + return redirect(url_for('admin')) # Перенаправляем после редактирования + + except (ValueError, TypeError): + logging.error("Админ: Получен некорректный индекс продукта для редактирования.") + return "Ошибка: Некорректный индекс продукта.", 400 + + elif action == 'delete': - index = int(request.form.get('index')) - del products[index] - save_data(data) - return redirect(url_for('admin')) - + try: + index = int(request.form.get('index')) + if 0 <= index < len(products): + deleted_product_name = products[index]['name'] + del products[index] + save_data(data) # Сохраняем обновленные данные + logging.info(f"Админ: Товар '{deleted_product_name}' (индекс {index}) успешно удален.") + return redirect(url_for('admin')) # Перенаправляем после удаления + else: + logging.warning(f"Админ: Попытка удаления продукта по некорректному индексу {index}.") + return "Ошибка: Некорректный индекс продукта.", 400 + except (ValueError, TypeError): + logging.error("Админ: Получен некорректный индекс продукта для удаления.") + return "Ошибка: Некорректный индекс продукта.", 400 + + # Если action не распознан + logging.warning(f"Админ: Получен нераспознанный action POST: {action}") + return "Неизвестное действие.", 400 + + # GET запрос - отображение админ-панели admin_html = ''' @@ -826,6 +1296,7 @@ def admin(): align-items: center; padding: 15px 0; border-bottom: 1px solid var(--primary-color); + margin-bottom: 20px; } .header-logo { width: 60px; @@ -856,21 +1327,32 @@ def admin(): font-weight: 500; margin-top: 15px; display: block; + margin-bottom: 5px; } - input, textarea, select { + input[type="text"], input[type="number"], textarea, select { width: 100%; padding: 12px; - margin-top: 5px; + margin-bottom: 10px; # Уменьшен отступ снизу border: 1px solid var(--secondary-color); border-radius: 8px; font-size: 1rem; transition: all 0.3s ease; + box-sizing: border-box; # Включаем padding и border в общую ширину } + input[type="file"] { + width: 100%; + padding: 12px 0; # Отступы для input file + margin-bottom: 10px; + } + input:focus, textarea:focus, select:focus { border-color: var(--primary-color); box-shadow: 0 0 5px rgba(244, 143, 177, 0.3); outline: none; } + .button-group { + margin-top: 20px; + } button { padding: 12px 20px; border: none; @@ -880,7 +1362,8 @@ def admin(): font-weight: 500; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - margin-top: 15px; + margin-right: 10px; # Отступ между кнопками + margin-top: 10px; # Отступ св��рху } button:hover { background-color: #E91E63; @@ -897,6 +1380,7 @@ def admin(): .product-list, .category-list { display: grid; gap: 20px; + margin-top: 20px; } .product-item, .category-item { background: var(--light-text); @@ -904,104 +1388,188 @@ def admin(): border-radius: 15px; box-shadow: 0 4px 15px var(--shadow-color); } + .category-item { + display: flex; + justify-content: space-between; + align-items: center; + } + .category-item h3 { + margin: 0; # Убираем отступ у заголовка категории + flex-grow: 1; + margin-right: 10px; + } + .category-item form { + margin: 0; + padding: 0; + box-shadow: none; + background: none; + } + .edit-form { margin-top: 15px; padding: 15px; background: #f7fafc; border-radius: 10px; + box-shadow: none; # Убираем тень у вложенной формы } .color-input-group { display: flex; gap: 10px; - margin-top: 5px; - } + align-items: center; # Выравнивание по центру + margin-bottom: 10px; # Отступ между группами цветов + } + .color-input-group input[type="text"] { + flex-grow: 1; # Инпут занимает доступное место + margin-bottom: 0; # Убираем нижний отступ у инпута внутри группы + } + .remove-color-btn { + background-color: #9E9E9E; # Серый цвет для кнопки удаления + margin: 0; # Убираем отступы + padding: 10px; + border-radius: 50%; # Делаем круглый + width: 30px; + height: 30px; + display: flex; + justify-content: center; + align-items: center; + font-size: 0.8rem; + } + .remove-color-btn:hover { + background-color: #757575; + box-shadow: none; + transform: none; + } + .add-color-btn { background-color: var(--secondary-color); + margin-right: 10px; # Отступ от следующей кнопки + margin-top: 0; # Убираем верхний отступ } .add-color-btn:hover { background-color: #BA68C8; } + .product-photos { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + } + .product-photos img { + max-width: 80px; # Уменьшаем размер превью + height: auto; + border-radius: 5px; + border: 1px solid #eee; + } + details summary { + cursor: pointer; + font-weight: 600; + color: var(--primary-color); + margin-top: 15px; + } + details summary:focus { + outline: none; + } + details[open] summary { + margin-bottom: 15px; + }
- + {# Используем переменную logo_url #}

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

+

Добавление товара

- - - - - - - - + + + + + + - - + + -
+
+
- - + +
+ +

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

- - - + + +
+ +
-

Список категорий

+

Список категорий (всего: {{ categories|length }})

{# Показываем количество #} + {% if categories %}
{% for category in categories %}
-

{{ category }}

-
+

{{ category|e }}

{# Экранируем #} + {# Используем inline-block #} - + {# Добавляем подтверждение #}
{% endfor %}
+ {% else %} +

Нет добавленных категорий.

+ {% endif %} -

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

-
- -
-
- -
-

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

+

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

{# Обновленный заголовок #} +
{# Группируем кнопки #} +
+ +
+
+ +
+
{# Ссылка на новый эндпоинт #} + +
+
+ +

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

{# Показываем количество #} + {% if products %}
{% for product in products %}
-

{{ product['name'] }}

-

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

-

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

-

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

-

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

+

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

{# Экранируем #} +

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

{# Экранируем #} +

Цена: {{ product.get('price', 'N/A')|e }} с

{# Экранируем #} +

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

{# Экранируем #} +

Цвета: {{ product.get('colors', ['Не указан'])|join(', ')|e }}

{# Экранируем #} {% if product.get('photos') and product['photos']|length > 0 %} -
+
{# Применяем класс для галереи фото #} {% for photo in product['photos'] %} - {{ product['name'] }} + {{ product['name']|e }} {% endfor %}
{% endif %} @@ -1011,70 +1579,151 @@ def admin(): - + {# Экранируем #} - + {# Экранируем #} - + {# Экранируем #} - + {# Уточняем #}
{% for color in product.get('colors', []) %}
- + {# Экранируем #} +
{% endfor %} + {# Если нет цветов, добавляем один пустой инпут при открытии для удобства #} + {% if not product.get('colors') %} +
+ + +
+ {% endif %}
- +
+ +
-
- - - -
+
{# Выравниваем кнопку удаления #} +
{# Используем inline-block #} + + + {# Добавляем подтверждение #} +
+
{% endfor %}
+ {% else %} +

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

+ {% endif %}
''' - return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID) + # Передаем logo_url как отдельную переменную + return render_template_string(admin_html, products=products, categories=categories, repo_id=REPO_ID, logo_url=LOGO_URL) + @app.route('/backup', methods=['POST']) def backup(): - upload_db_to_hf() - return "Резервная копия создана.", 200 + """Вручную создает резервную копию базы данных на Hugging Face.""" + logging.info("Получен запрос на ручное резервное копирование.") + try: + # Нет необходимости загружать данные здесь, просто загружаем локальный файл + upload_db_to_hf() + return "Резервная копия базы данных успешно создана на Hugging Face.", 200 + except Exception as e: + logging.error(f"Ошибка ручного резервного копирования: {e}") + return f"Произошла ошибка при создании резервной копии: {e}", 500 @app.route('/download', methods=['GET']) def download(): - download_db_from_hf() - return "База данных скачана.", 200 + """Вручную скачивает базу данных с Hugging Face.""" + logging.info("Получен запрос на ручное скачивание базы данных.") + try: + download_db_from_hf() + # Вместо простого сообщения, отправляем сам файл для скачивания + return send_file(DATA_FILE, as_attachment=True, download_name=DATA_FILE) + except FileNotFoundError: + return f"Файл '{DATA_FILE}' не найден локально после попытки скачивания. Репозиторий может быть пуст или не существует.", 404 + except RepositoryNotFoundError: + return f"Репозиторий '{REPO_ID}' не найден на Hugging Face.", 404 + except Exception as e: + logging.error(f"Ошибка ручного скачивания базы данных: {e}") + return f"Произошла ошибка при скачивании базы данных: {e}", 500 + if __name__ == '__main__': + # Запускаем периодический бэкап в отдельном потоке + logging.info("Запуск потока периодического резервного копирования.") backup_thread = threading.Thread(target=periodic_backup, daemon=True) backup_thread.start() + + # При старте приложения пытаемся загрузить данные + logging.info("Попытка загрузки данных при старте приложения.") try: load_data() + logging.info("Данные успешно загружены или инициализированы при старте.") except Exception as e: - logging.error(f"Не удалось загрузить базу данных: {e}") - app.run(debug=True, host='0.0.0.0', port=7860) \ No newline at end of file + logging.error(f"Критическая ошибка при загрузке/инициализации базы данных при старте: {e}") + # Приложение может продолжить работу с пустой базой, но нужно уведомить + + # Запускаем Flask приложение + logging.info("Запуск Flask приложения.") + # debug=True следует использовать только при разработке. + # Для production используйте продакшн-сервер (Gunicorn, uWSGI и т.д.) + app.run(debug=True, host='0.0.0.0', port=7860) + + + +