diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -8,23 +8,17 @@ import threading import time from datetime import datetime from huggingface_hub import HfApi, hf_hub_download -from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError # Import specific HF errors +from huggingface_hub.utils import RepositoryNotFoundError, hf_raise_for_status # Import hf_raise_for_status for detailed errors from werkzeug.utils import secure_filename # Импортируем dotenv для загрузки переменных окружения из .env файла from dotenv import load_dotenv # Загружаем переменные окружения из файла .env (если он есть) -# Убедитесь, что у вас есть файл .env в той же директории, что и этот скрипт -# Пример .env файла: -# HF_TOKEN="hf_YOUR_WRITE_TOKEN_HERE" -# HF_TOKEN_READ="hf_YOUR_READ_TOKEN_HERE" # Может быть таким же или другим load_dotenv() app = Flask(__name__) -# ВАЖНО: Замените 'your_unique_secret_key_soola_cosmetics_67890' на действительно случайную и секретную строку! -# Можно сгенерировать, например, с помощью: import secrets; secrets.token_hex(16) -app.secret_key = os.getenv('FLASK_SECRET_KEY', 'your_unique_secret_key_soola_cosmetics_67890') # Используем env переменную или дефолт - +# Новый уникальный секретный ключ, ОБЯЗАТЕЛЬНО смените его в продакшене или используйте переменную окружения +app.secret_key = os.getenv('FLASK_SECRET_KEY', 'fallback_secret_key_soola_cosmetics_12345_CHANGE_ME') DATA_FILE = 'data_soola.json' USERS_FILE = 'users_soola.json' @@ -33,58 +27,61 @@ SYNC_FILES = [DATA_FILE, USERS_FILE] # Настройки Hugging Face # Убедитесь, что REPO_ID соответствует вашему репозиторию на Hugging Face -REPO_ID = "Kgshop/Soola" # Замените на ваш ID, если он другой -HF_TOKEN_WRITE = os.getenv("HF_TOKEN") -HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") # Может быть тем же, что и HF_TOKEN +REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/Soola") # Используем переменную окружения или значение по умолчанию +HF_TOKEN = os.getenv("HF_TOKEN") # Универсальный токен для чтения и записи +# Убраны отдельные токены WRITE/READ, так как HF_TOKEN используется для обоих +# HF_TOKEN_WRITE = os.getenv("HF_TOKEN") +# HF_TOKEN_READ = os.getenv("HF_TOKEN") # Адрес магазина -STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38" # Единый адрес +STORE_ADDRESS = os.getenv("STORE_ADDRESS", "Рынок Дордой, Джунхай, терминал, 38") # Единый адрес # Валюта (только KGS) CURRENCY_CODE = 'KGS' CURRENCY_NAME = 'Кыргызский сом (с)' # Настройка логирования -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(threadName)s - %(message)s', - handlers=[ - logging.FileHandler("app_soola.log", encoding='utf-8'), # Лог в файл - logging.StreamHandler() # Лог в консоль - ] -) +# Уровни: DEBUG, INFO, WARNING, ERROR, CRITICAL +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s') + +# Путь для временной загрузки фото перед отправкой на HF +UPLOAD_FOLDER = 'uploads_temp' +os.makedirs(UPLOAD_FOLDER, exist_ok=True) # --- Функции работы с данными и пользователями --- def load_data(): """Загрузка данных о товарах и категориях.""" + logging.info(f"Попытка загрузки данных из {DATA_FILE}...") + try: + # Сначала пытаемся скачать свежие данные + download_db_from_hf(specific_file=DATA_FILE) + except Exception as e: + logging.warning(f"Ошибка при попытке скачивания {DATA_FILE} перед загрузкой: {e}. Продолжаем с локальным файлом.") + try: - # Попытка скачать актуальные данные перед чтением локальных - download_db_from_hf(specific_file=DATA_FILE) # Пытаемся скачать свежий файл with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) logging.info(f"Данные успешно загружены из локального {DATA_FILE}") - # Проверка базовой структуры + # Проверка и инициализация базовой структуры if not isinstance(data, dict): logging.warning(f"{DATA_FILE} не является словарем. Инициализация пустой структурой.") - return {'products': [], 'categories': []} + data = {'products': [], 'categories': []} if 'products' not in data: - logging.warning(f"Ключ 'products' отсутствует в {DATA_FILE}. Добавлен пустой список.") data['products'] = [] if 'categories' not in data: - logging.warning(f"Ключ 'categories' отсутствует в {DATA_FILE}. Добавлен пустой список.") data['categories'] = [] return data except FileNotFoundError: - logging.warning(f"Локальный файл {DATA_FILE} не найден после попытки скачивания. Создание пустой структуры.") - # Создаем пустой файл, если он все еще не существует + logging.warning(f"Локальный файл {DATA_FILE} не найден. Возврат пустой структуры.") + # Файл мог быть скачан выше, но если нет, создаем пустой if not os.path.exists(DATA_FILE): with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump({'products': [], 'categories': []}, f) logging.info(f"Создан пустой файл {DATA_FILE}") return {'products': [], 'categories': []} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.") - # Попытка восстановить из бэкапа или создать пустой, если нужно + # Можно добавить логику восстановления из бэкапа или принудительного скачивания return {'products': [], 'categories': []} except Exception as e: logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True) @@ -94,31 +91,39 @@ def load_data(): def save_data(data): """Сохранение данных о товарах и категориях.""" try: - # Сортировка перед сохранением для консистентности - data.get('products', []).sort(key=lambda x: x.get('name', '').lower()) - data.get('categories', []).sort() + # Сортировка продуктов по имени перед сохранением + if 'products' in data and isinstance(data['products'], list): + data['products'].sort(key=lambda x: x.get('name', '').lower()) + # Сортировка категорий перед сохранением + if 'categories' in data and isinstance(data['categories'], list): + data['categories'].sort() + with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) logging.info(f"Данные успешно сохранены в {DATA_FILE}") - # Загрузка на HF после сохранения (можно сделать опциональной) - # Вызываем в отдельном потоке, чтобы не блокировать основной запрос - upload_thread = threading.Thread(target=upload_db_to_hf, args=(DATA_FILE,), name="HFUploadDataThread") - upload_thread.start() + # Загрузка на HF после сохранения + upload_db_to_hf(specific_file=DATA_FILE) except Exception as e: logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}", exc_info=True) - flash("Ошибка при сохранении данных. См. лог сервера.", "error") + flash("Произошла ошибка при сохранении данных. Изменения могут быть не сохранены.", 'error') def load_users(): """Загрузка данных пользователей.""" + logging.info(f"Попытка загрузки пользователей из {USERS_FILE}...") + try: + # Сначала пытаемся скачать свежие данные + download_db_from_hf(specific_file=USERS_FILE) + except Exception as e: + logging.warning(f"Ошибка при попытке скачивания {USERS_FILE} перед загрузкой: {e}. Продолжаем с локальным файлом.") + try: - download_db_from_hf(specific_file=USERS_FILE) # Пытаемся скачать свежий файл with open(USERS_FILE, 'r', encoding='utf-8') as file: users = json.load(file) logging.info(f"Данные пользователей успешно загружены из локального {USERS_FILE}") return users if isinstance(users, dict) else {} except FileNotFoundError: - logging.warning(f"Локальный файл {USERS_FILE} не найден после попытки скачивания. Создание пустого файла.") + logging.warning(f"Локальный файл {USERS_FILE} не найден. Возврат пустого словаря.") if not os.path.exists(USERS_FILE): with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) logging.info(f"Создан пустой файл {USERS_FILE}") @@ -133,58 +138,48 @@ def load_users(): def save_users(users): """Сохранение данных пользователей.""" try: - # Сортировка пользователей по логину для консистентности - sorted_users = dict(sorted(users.items())) with open(USERS_FILE, 'w', encoding='utf-8') as file: - json.dump(sorted_users, file, ensure_ascii=False, indent=4) + json.dump(users, file, ensure_ascii=False, indent=4) logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}") # Загрузка на HF после сохранения - upload_thread = threading.Thread(target=upload_db_to_hf, args=(USERS_FILE,), name="HFUploadUsersThread") - upload_thread.start() + upload_db_to_hf(specific_file=USERS_FILE) except Exception as e: logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True) - flash("Ошибка при сохранении данных пользователей. См. лог сервера.", "error") + flash("Произошла ошибка при сохранении данных пользователей.", 'error') # --- Функции синхронизации с Hugging Face --- -# Используем Lock для предотвращения одновременной записи/чтения во время синхронизации, если нужно -# hf_lock = threading.Lock() # Раскомментировать, если возникают проблемы с гонкой состояний - def upload_db_to_hf(specific_file=None): """Загрузка файлов данных на Hugging Face. Если specific_file указан, загружает только его. """ - if not HF_TOKEN_WRITE: - logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.") - return False # Возвращаем статус неудачи - - files_to_upload = [specific_file] if specific_file else SYNC_FILES - success = True # Флаг общего успеха - - # with hf_lock: # Раскомментировать, если используется блокировка + if not HF_TOKEN: + logging.warning("Переменная окружения HF_TOKEN не установлена. Загрузка на Hugging Face пропущена.") + return False # Возвращаем False при пропуске try: api = HfApi() + files_to_upload = [specific_file] if specific_file else SYNC_FILES logging.info(f"Начало загрузки файлов {files_to_upload} на HF репозиторий {REPO_ID}...") - + success = True for file_name in files_to_upload: if os.path.exists(file_name): try: + logging.debug(f"Загрузка файла: {file_name}...") api.upload_file( path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID, repo_type="dataset", - token=HF_TOKEN_WRITE, + token=HF_TOKEN, commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" ) logging.info(f"Файл {file_name} успешно загружен на Hugging Face.") except Exception as e: logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}") - success = False # Отмечаем неудачу, если хоть один файл не загрузился - # Продолжаем пытаться загрузить другие файлы + success = False # Отмечаем неудачу + # Можно добавить flash сообщение об ошибке, но это серверная функция else: logging.warning(f"Файл {file_name} не найден локально, пропуск загрузки.") - success = False # Считаем это тоже неполным успехом logging.info("Загрузка файлов на HF завершена.") return success except Exception as e: @@ -194,240 +189,229 @@ def upload_db_to_hf(specific_file=None): def download_db_from_hf(specific_file=None): """Скачивание файлов данных с Hugging Face. Если specific_file указан, скачивает только его. - Возвращает True, если хотя бы один файл был успешно скачан, иначе False. + Возвращает True, если хотя бы один файл успешно скачан, иначе False. """ + # HF_TOKEN необязателен для публичных репо, но нужен для приватных + # Логика теперь использует HF_TOKEN если он есть + token_msg = "с использованием токена" if HF_TOKEN else "без токена (для публичных репо)" files_to_download = [specific_file] if specific_file else SYNC_FILES - logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID}...") + logging.info(f"Начало скачивания файлов {files_to_download} с HF репозитория {REPO_ID} {token_msg}...") downloaded_files_count = 0 - # with hf_lock: # Раскомментировать, если используется блокировка try: for file_name in files_to_download: try: - # Скачиваем в текущую директорию, перезаписывая существующие файлы + logging.debug(f"Попытка скачивания файла: {file_name}...") local_path = hf_hub_download( repo_id=REPO_ID, filename=file_name, repo_type="dataset", - token=HF_TOKEN_READ, # Передаем токен, если он есть (может быть None) - local_dir=".", # Скачиваем в текущую директорию - local_dir_use_symlinks=False, # Избегаем символических ссылок - force_download=True, # Принудительно скачивать свежую версию - resume_download=False # Отключаем возобновление для простоты + token=HF_TOKEN, # Передаем токен, если он есть + local_dir=".", + local_dir_use_symlinks=False, # Важно для Render/контейнеров + force_download=True # Принудительно скачивать свежую версию ) - # Переименовываем скачанный файл из кеша в нужное место - # hf_hub_download возвращает путь в кеше, нужно скопировать/переместить - if os.path.exists(local_path): - os.replace(local_path, file_name) # Перемещаем с заменой - logging.info(f"Файл {file_name} успешно скачан из Hugging Face и сохранен как {file_name}.") - downloaded_files_count += 1 - else: - logging.error(f"hf_hub_download вернул путь {local_path}, но файл не найден.") - - except HfHubHTTPError as e: - # Проверяем, является ли ошибка 'Not Found' для конкретного файла - if e.response.status_code == 404: - logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропуск скачивания этого файла.") - else: - logging.error(f"HTTP ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True) + logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.") + downloaded_files_count += 1 except RepositoryNotFoundError: logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.") - break # Прерываем цикл, если репозиторий не найден + return False # Прерываем цикл и возвращаем неудачу except Exception as e: - # Логируем другие ошибки - logging.error(f"Общая ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True) - - logging.info(f"Скачивание файлов с HF завершено. Успешно скачано: {downloaded_files_count}/{len(files_to_download)}.") - return downloaded_files_count > 0 # Успех, если хоть что-то скачали + # Используем hf_raise_for_status для более детальной информации, если это HTTP ошибка + try: + hf_raise_for_status(e.response) # Проверяем статус HTTP, если это HTTPError + except Exception as http_e: + # Ловим ошибку файла не найдено (404) отдельно + if "404" in str(http_e): + logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропуск скачивания этого файла.") + continue # Переходим к следующему файлу + else: + logging.error(f"Ошибка HTTP при скачивании файла {file_name} с Hugging Face: {http_e}") + continue # Переходим к следующему файлу, но логируем ошибку + + # Если это не HTTP ошибка или не 404 + logging.error(f"Не-HTTP ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=False) # Не печатаем весь traceback для FileNotFoundError + + logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.") + return downloaded_files_count > 0 except RepositoryNotFoundError: logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание не выполнено.") return False except Exception as e: - logging.error(f"Критическая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True) + logging.error(f"Общая ошибка при попытке скачивания с Hugging Face: {e}", exc_info=True) return False def periodic_backup(): """Периодическая загрузка данных на HF.""" - backup_interval = 1800 # 30 минут = 1800 секунд + backup_interval = 1800 # 30 минут (в секундах) logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.") while True: time.sleep(backup_interval) logging.info("Запуск периодического резервного копирования...") - upload_db_to_hf() # Загружает все SYNC_FILES - logging.info("Периодическое резервное копирование завершено.") + try: + success = upload_db_to_hf() # Загружает все SYNC_FILES + if success: + logging.info("Периодическое резервное копирование успешно завершено.") + else: + logging.warning("Периодическое резервное копирование завершено с ошибками.") + except Exception as e: + logging.error(f"Критическая ошибка в потоке периодического бэкапа: {e}", exc_info=True) -# --- HTML Шаблоны --- -CATALOG_TEMPLATE = ''' - - - - - - Soola Cosmetics - Каталог - - - - - - -
-
-

Soola Cosmetics

-
-
Наш адрес: {{ store_address }}
+
Н��ш адрес: {{ store_address }}
-
{% for category in categories %} @@ -436,712 +420,796 @@ CATALOG_TEMPLATE = '''
- +
-
-
- {% for product in products %} -
-
- {% if product.get('photos') and product['photos']|length > 0 %} - {# Используем правильный URL для Hugging Face Datasets #} - {{ product['name'] }} - {% else %} - No Image - {% endif %} -
-
-

{{ product['name'] }}

- {% if is_authenticated %} -
{{ "%.2f"|format(product['price']) }} {{ currency_code }}
- {% else %} -
Цена доступна после входа
- {% endif %} -

{{ product.get('description', '') }}

-
-
- - {% if is_authenticated %} - - {% endif %} +
+ {% for product in products %} +
+
+ {% if product.get('photos') and product['photos']|length > 0 %} + {# Используем URL из константы REPO_ID #} + {{ product['name'] }} + {% else %} + No Image + {% endif %} +
+
+

{{ product['name'] }}

+ {% if is_authenticated %} +
{{ "%.2f"|format(product['price']) }} {{ currency_code }}
+ {% else %} +
Цена доступна после входа
+ {% endif %} +

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

+
+
+ + {% if is_authenticated %} + + {% endif %} +
+ {% endfor %} + {# Сообщение, если нет товаров ПОСЛЕ фильтрации, будет добавлено через JS #} + {% if not products %} +

Товары пока не добавлены.

+ {% endif %}
- {% endfor %} - {# Сообщение, если нет товаров ПОСЛЕ фильтрации, будет добавлено через JS #} - {% if not products %} -

Товары пока не добавлены.

- {% endif %}
-
- -