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; # Отступы между кнопками + } }
{{ product['description'][:50] }}{% if product['description']|length > 50 %}...{% endif %}
+{{ product['description'][:50]|e }}{% if product['description']|length > 50 %}...{% endif %}
# Экранируем описаниеНет товаров в каталоге.
+ {% endif %}