from flask import Flask, render_template_string, request, redirect, url_for, send_file, flash, jsonify, session
import json
import os
import logging
import threading
import time
from datetime import datetime
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub.utils import RepositoryNotFoundError, HfHubHTTPError
from werkzeug.utils import secure_filename
from dotenv import load_dotenv
import requests
import uuid
load_dotenv()
app = Flask(__name__)
app.secret_key = os.getenv("FLASK_SECRET_KEY", 'your_unique_secret_key_integrity_shop_67890_no_login')
DATA_FILE = 'data.json'
SYNC_FILES = [DATA_FILE]
REPO_ID = "Kgshop/integrity"
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
CURRENCY_CODE = 'KGS'
ADMIN_CURRENCY_CODE = 'USD'
DOWNLOAD_RETRIES = 3
DOWNLOAD_DELAY = 5
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
translations = {
'ru': {
'site_name': 'Integrity',
'catalog_title': 'Integrity - Каталог',
'admin_title': 'Админ-панель - Integrity',
'order_title': 'Заказ №{order_id} - Integrity',
'all_categories': 'Все категории',
'all_suppliers': 'Все поставщики',
'search_placeholder': 'Поиск по названию или описанию...',
'no_products_yet': 'Товары пока не добавлены.',
'details': 'Подробнее',
'add_to_cart': 'В корзину',
'loading': 'Загрузка...',
'specify_quantity_color': 'Укажите количество и цвет',
'quantity': 'Количество:',
'color_variant': 'Цвет/Вариант:',
'confirm_add_to_cart': 'Добавить в корзину',
'your_cart': 'Ваша корзина',
'cart_empty': 'Ваша корзина пуста.',
'total': 'Итого:',
'clear_cart': 'Очистить корзину',
'formulate_order': 'Сформировать заказ',
'open_cart_aria': 'Открыть корзину',
'error_product_not_found': 'Ошибка: товар не найден.',
'error_invalid_quantity': 'Пожалуйста, укажите корректное количество (больше 0).',
'error_adding_product_not_found': 'Ошибка добавления: товар не найден.',
'product_added_to_cart': '{product_name} добавлен в корзину!',
'cart_item_color_prefix': ' (Цвет: ',
'cart_item_color_suffix': ')',
'remove_item_title': 'Удалить товар',
'confirm_clear_cart': 'Вы уверены, что хотите очистить корзину?',
'cart_empty_for_order': 'Корзина пуста! Добавьте товары перед формированием заказа.',
'formulating_order': 'Формируем заказ...',
'failed_to_create_order': 'Не удалось создать заказ',
'order_id_not_received': 'Не получен ID заказа от сервера.',
'error_generic': 'Ошибка: {error_message}',
'no_products_found_for_query': 'По вашему запросу товары не найдены.',
'product_detail_photo_alt': '{product_name} - фото {loop_index}',
'no_image_alt': 'Изображение отсутствует',
'category': 'Категория:',
'supplier': 'Поставщик:',
'no_category': 'Без категории',
'no_supplier': 'Без поставщика',
'price': 'Цена:',
'description': 'Описание:',
'description_absent': 'Описание отсутствует.',
'available_colors_variants': 'Доступные цвета/варианты:',
'order_page_title': 'Ваш Заказ №{order_id}',
'creation_date': 'Дата создания:',
'products_in_order': 'Товары в заказе',
'total_products_sum': 'Общая сумма товаров:',
'total_to_pay': 'ИТОГО К ОПЛАТЕ:',
'order_status': 'Статус заказа',
'anonymous_order_notice': 'Этот заказ был оформлен без входа в систему.',
'contact_whatsapp_notice': 'Пожалуйста, свяжитесь с нами по WhatsApp для подтверждения и уточнения деталей.',
'send_order_button': 'Отправить в WhatsApp',
'back_to_catalog': '← Вернуться в каталог',
'error_page_title': 'Ошибка',
'order_not_found_by_id': 'Заказ с таким ID не найден.',
'whatsapp_greeting': 'Здравствуйте! Хочу подтвердить свой заказ на Integrity:',
'whatsapp_order_number': 'Номер заказа:',
'whatsapp_order_link': 'Ссылка на заказ:',
'whatsapp_contact_request': 'Пожалуйста, свяжитесь со мной для уточнения деталей оплаты и доставки.',
'whatsapp_request_price_calculation': 'Пожалуйста, рассчитайте стоимость заказа.',
'admin_panel_h1': 'Админ-панель Integrity',
'go_to_catalog': 'Перейти в каталог',
'sync_with_dc': 'Синхронизация с Датацентром',
'upload_db': 'Загрузить БД',
'download_db': 'Скачать БД',
'confirm_force_upload': 'Вы уверены, что хотите принудительно загрузить локальные данные на сервер? Это перезапишет данные на сервере.',
'confirm_force_download': 'Вы уверены, что хотите принудительно скачать данные с сервера? Это перезапишет ваши локальные файлы.',
'sync_info_text': 'Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для немедленной синхронизации.',
'manage_categories': 'Управление категориями',
'add_new_category': 'Добавить новую категорию',
'new_category_name_label': 'Название новой категории:',
'add_button': 'Добавить',
'existing_categories': 'Существующие категории:',
'confirm_delete_category': "Вы уверены, что хотите удалить категорию '{category_name}'? Товары этой категории будут помечены как 'Без категории'.",
'no_categories_yet': 'Категорий пока нет.',
'manage_suppliers': 'Управление поставщиками',
'add_new_supplier': 'Добавить нового поставщика',
'new_supplier_name_label': 'Название нового поставщика:',
'existing_suppliers': 'Существующие поставщики:',
'confirm_delete_supplier': "Вы уверены, что хотите удалить поставщика '{supplier_name}'? Товары этого поставщика будут помечены как 'Без поставщика'.",
'no_suppliers_yet': 'Поставщиков пока нет.',
'information_section_title': 'Информация',
'user_management_disabled_info': 'Управление пользователями отключено, так как сайт не требует входа.',
'anonymous_orders_whatsapp_info': 'Заказы создаются анонимно и должны быть подтверждены через WhatsApp.',
'manage_products': 'Управление товарами',
'add_new_product': 'Добавить новый товар',
'product_name_label': 'Название товара *:',
'product_price_usd_label': 'Цена (USD) *:',
'product_discount_percent_label': 'Скидка (%):',
'product_description_label': 'Описание:',
'product_category_label': 'Категория:',
'product_supplier_label': 'Поставщик:',
'no_category_option': 'Без категории',
'no_supplier_option': 'Без поставщика',
'photos_label': 'Фотографии (до 10 шт.):',
'colors_variants_label': 'Цвета/Варианты (оставьте пустым, если нет):',
'color_placeholder_example': 'Например: Синий',
'add_color_field_button': 'Добавить поле для цвета/варианта',
'in_stock_label': 'В наличии',
'top_product_label': 'Топ товар (показывать наверху)',
'add_product_button': 'Добавить товар',
'product_list_title': 'Список товаров:',
'view_first_photo_title': 'Посмотреть первое фото',
'photo_alt': 'Фото',
'no_photo_alt': 'Нет фото',
'status_in_stock': 'В наличии',
'status_out_of_stock': 'Нет в наличии',
'status_top_product': 'Топ',
'description_label_item': 'Описание:',
'description_not_available': 'Отсутствует',
'colors_variants_item_label': 'Цвета/Вар-ты:',
'colors_not_available': 'Нет',
'discount_label_item': 'Скидка:',
'discount_badge_text': 'Скидка {discount}%',
'total_photos_count': '(Всего фото: {count})',
'edit_button': 'Редактировать',
'confirm_delete_product': "Вы уверены, что хотите удалить товар '{product_name}'?",
'delete_button': 'Удалить',
'editing_product_title': 'Редактирование: {product_name}',
'product_name_edit_label': 'Название *:',
'product_price_usd_edit_label': 'Цена (USD) *:',
'product_discount_percent_edit_label': 'Скидка (%):',
'product_description_edit_label': 'Описание:',
'product_category_edit_label': 'Категория:',
'product_supplier_edit_label': 'Поставщик:',
'replace_photos_label': 'Заменить фотографии (выберите новые файлы, до 10 шт.):',
'current_photos_label': 'Текущие фото:',
'photo_alt_indexed': 'Фото {loop_index}',
'colors_variants_edit_label': 'Цвета/Варианты:',
'color_placeholder_generic': 'Например: Цвет',
'add_color_field_edit_button': 'Добавить поле для цвета',
'in_stock_edit_label': 'В наличии',
'top_product_edit_label': 'Топ товар',
'save_changes_button': 'Сохранить изменения',
'no_products_yet_admin': 'Товаров пока нет.',
'js_new_color_variant_placeholder': 'Новый цвет/вариант',
'flash_category_added_success': "Категория '{category_name}' успешно добавлена.",
'flash_category_name_empty': 'Название категории не может быть пустым.',
'flash_category_already_exists': "Категория '{category_name}' уже существует.",
'flash_category_delete_success': "Категория '{category_name}' удалена. {updated_count} товаров обновлено.",
'flash_category_delete_failed': "Не удалось удалить категорию '{category_name}'.",
'flash_supplier_added_success': "Поставщик '{supplier_name}' успешно добавлен.",
'flash_supplier_name_empty': 'Имя поставщика не может быть пустым.',
'flash_supplier_already_exists': "Поставщик '{supplier_name}' уже существует.",
'flash_supplier_delete_success': "Поставщик '{supplier_name}' удален. {updated_count} товаров обновлено.",
'flash_supplier_delete_failed': "Не удалось удалить поставщика '{supplier_name}'.",
'flash_product_name_price_required': 'Название и цена товара обязательны.',
'flash_invalid_price_format': 'Неверный формат цены.',
'flash_invalid_discount_format': 'Неверный формат скидки (0-100).',
'flash_photo_limit_reached': 'Загружено только первые {photo_limit} фото.',
'flash_file_not_image_skipped': 'Файл {filename} не является изображением и был пропущен.',
'flash_photo_upload_error': 'Ошибка при загрузке фото {filename}.',
'flash_hf_token_write_not_set_photos_not_uploaded': 'HF_TOKEN (write) не настроен. Фотографии не были загружены.',
'flash_product_added_success': "Товар '{product_name}' успешно добавлен.",
'flash_edit_error_no_id': 'Ошибка редактирования: ID товара не передан.',
'flash_edit_error_not_found': "Ошибка редактирования: товар с ID '{product_id}' не найден.",
'flash_invalid_price_format_edit_warning': "Неверный формат цены для товара '{product_name}'. Цена не изменена.",
'flash_invalid_discount_format_edit_warning': "Неверный формат скидки для товара '{product_name}'. Скидка не изменена.",
'flash_photos_updated_success': 'Фотографии товара успешно обновлены.',
'flash_failed_to_upload_new_photos_format_error': 'Не удалось загрузить новые фотографии (возможно, неверный формат).',
'flash_hf_token_write_not_set_photos_not_updated': 'HF_TOKEN (write) не настроен. Фотографии не были обновлены.',
'flash_product_updated_success': "Товар '{product_name}' успешно обновлен.",
'flash_delete_error_no_id': 'Ошибка удаления: ID товара не передан.',
'flash_delete_error_not_found': "Ошибка удаления: товар с ID '{product_id}' не найден.",
'flash_failed_delete_hf_photos_warning': "Не удалось удалить фото для товара '{product_name}' с сервера. Товар удален локально.",
'flash_hf_token_write_not_set_photos_not_deleted_warning': "Товар '{product_name}' удален локально, но фото не удалены с сервера (токен не задан).",
'flash_product_deleted_success': "Товар '{product_name}' удален.",
'flash_unknown_action': 'Неизвестное действие: {action}',
'flash_internal_error': "Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.",
'flash_data_uploaded_hf_success': 'Данные успешно загружены на Hugging Face.',
'flash_data_upload_hf_error': 'Ошибка при загрузке на Hugging Face: {error}',
'flash_data_downloaded_hf_success': 'Данные успешно скачаны с Hugging Face. Локальные файлы обновлены.',
'flash_data_download_hf_error_retries': 'Не удалось скачать данные с Hugging Face после нескольких попыток. Проверьте логи.',
'flash_data_download_hf_error': 'Ошибка при скачивании с Hugging Face: {error}',
'currency_name': 'Кыргызский сом',
'close_button_aria': 'Закрыть',
'admin_settings_title': 'Настройки магазина',
'admin_usd_to_kgs_rate_label': 'Курс USD к KGS (например: 87.50):',
'admin_prices_visibility_label': 'Видимость цен на сайте:',
'admin_prices_enabled_checkbox_label': 'Цены включены',
'save_settings_button': 'Сохранить настройки',
'flash_settings_updated': 'Настройки успешно обновлены.',
'prices_disabled_notice_catalog': 'Цены временно не отображаются. Добавьте товары в корзину для запроса стоимости.',
'prices_disabled_notice_cart': 'Цены не отображаются. Стоимость будет рассчитана после оформления заказа.',
'request_price_calculation_button': 'Запросить расчет стоимости',
'price_on_request': 'Цена по запросу',
}
}
def get_translation(key, **kwargs):
translation_template = translations['ru'].get(key, f"MISSING_TRANSLATION: {key}")
return translation_template.format(**kwargs) if kwargs else translation_template
def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
files_to_download = [specific_file] if specific_file else SYNC_FILES
logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
all_successful = True
for file_name in files_to_download:
success = False
for attempt in range(retries + 1):
try:
logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
local_path = hf_hub_download(
repo_id=REPO_ID,
filename=file_name,
repo_type="dataset",
token=token_to_use,
local_dir=".",
local_dir_use_symlinks=False,
force_download=True,
resume_download=False
)
logging.info(f"Successfully downloaded {file_name} to {local_path}.")
success = True
break
except RepositoryNotFoundError:
logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
return False
except HfHubHTTPError as e:
if e.response.status_code == 404:
logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
if attempt == 0 and not os.path.exists(file_name):
try:
if file_name == DATA_FILE:
with open(file_name, 'w', encoding='utf-8') as f:
json.dump({'products': [], 'categories': [], 'suppliers': [], 'orders': {}, 'config': {'usd_to_kgs_rate': 87.0, 'prices_enabled': True}}, f)
logging.info(f"Created empty local file {file_name} because it was not found on HF.")
except Exception as create_e:
logging.error(f"Failed to create empty local file {file_name}: {create_e}")
success = False
break
else:
logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
except requests.exceptions.RequestException as e:
logging.error(f"Network error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
except Exception as e:
logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
if attempt < retries:
time.sleep(delay)
if not success and file_name == DATA_FILE:
logging.error(f"Failed to download critical file {file_name} after {retries + 1} attempts.")
all_successful = False
elif not success:
logging.warning(f"Failed to download non-critical file {file_name} after {retries + 1} attempts.")
logging.info(f"Download process finished. Overall success for critical files: {all_successful}")
return all_successful
def upload_db_to_hf(specific_file=None):
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (for writing) not set. Skipping upload to Hugging Face.")
return
try:
api = HfApi()
files_to_upload = [specific_file] if specific_file else SYNC_FILES
logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
for file_name in files_to_upload:
if os.path.exists(file_name):
try:
api.upload_file(
path_or_fileobj=file_name,
path_in_repo=file_name,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
except Exception as e:
logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
else:
logging.warning(f"File {file_name} not found locally, skipping upload.")
logging.info("Finished uploading files to HF.")
except Exception as e:
logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
def periodic_backup():
backup_interval = 1800
logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
while True:
time.sleep(backup_interval)
logging.info("Starting periodic backup...")
upload_db_to_hf()
logging.info("Periodic backup finished.")
def load_data():
default_data = {'products': [], 'categories': [], 'suppliers': [], 'orders': {}, 'config': {'usd_to_kgs_rate': 87.0, 'prices_enabled': True}}
# --- Function to process and clean data ---
def process_data(data, source="local"):
if not isinstance(data, dict):
logging.warning(f"Data from {source} source is not a dictionary. Using default.")
return default_data, False
data.setdefault('products', [])
data.setdefault('categories', [])
data.setdefault('suppliers', [])
data.setdefault('orders', {})
if 'config' not in data:
data['config'] = default_data['config']
else:
data['config'].setdefault('usd_to_kgs_rate', default_data['config'].get('usd_to_kgs_rate', 87.0))
if 'usd_to_uzs_rate' in data['config']: # Legacy currency code migration
data['config']['usd_to_kgs_rate'] = data['config'].pop('usd_to_uzs_rate')
try:
data['config']['usd_to_kgs_rate'] = float(data['config']['usd_to_kgs_rate'])
except (ValueError, TypeError):
data['config']['usd_to_kgs_rate'] = default_data['config']['usd_to_kgs_rate']
data['config'].setdefault('prices_enabled', default_data['config']['prices_enabled'])
needs_resave = False
for prod in data.get('products', []):
# *** FIX: Data Migration - Add unique ID to products that don't have one ***
if 'id' not in prod or not prod['id']:
prod['id'] = uuid.uuid4().hex
needs_resave = True
logging.info(f"Assigned new unique ID {prod['id']} to product '{prod.get('name', 'N/A')}'.")
prod.setdefault('supplier', 'Без поставщика')
# Price migration and validation
if 'price' in prod and 'price_usd' not in prod:
try:
prod['price_usd'] = float(prod.pop('price'))
except (ValueError, TypeError):
prod['price_usd'] = 0.0
prod.setdefault('price_usd', 0.0)
# Discount validation
prod.setdefault('discount_percent', 0)
try:
prod['discount_percent'] = int(prod['discount_percent'])
if not (0 <= prod['discount_percent'] <= 100):
prod['discount_percent'] = 0
except (ValueError, TypeError):
prod['discount_percent'] = 0
return data, needs_resave
# --- Main loading logic ---
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info(f"Local data loaded successfully from {DATA_FILE}")
processed_data, needs_save = process_data(data, "local")
if needs_save:
logging.info("Data migration performed on local file. Saving changes.")
save_data(processed_data) # Save back the migrated data
return processed_data
except (FileNotFoundError, json.JSONDecodeError) as e:
logging.warning(f"Could not load local file {DATA_FILE} ({e}). Attempting download from HF.")
if download_db_from_hf(specific_file=DATA_FILE):
try:
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
processed_data, needs_save = process_data(data, "downloaded")
if needs_save:
logging.info("Data migration performed on downloaded file. Saving changes locally and re-uploading.")
save_data(processed_data) # Save and re-upload migrated data
return processed_data
except (FileNotFoundError, json.JSONDecodeError, Exception) as e:
logging.error(f"Error processing downloaded {DATA_FILE}: {e}. Using default.", exc_info=True)
return default_data
else:
logging.error(f"Failed to download {DATA_FILE} from HF after retries. Using empty default data structure.")
if not os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'w', encoding='utf-8') as f:
json.dump(default_data, f)
logging.info(f"Created empty local file {DATA_FILE} after failed download.")
except Exception as create_e:
logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
return default_data
def save_data(data):
try:
if not isinstance(data, dict):
logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
return
default_config = {'usd_to_kgs_rate': 87.0, 'prices_enabled': True}
data.setdefault('products', [])
data.setdefault('categories', [])
data.setdefault('suppliers', [])
data.setdefault('orders', {})
if 'config' not in data:
data['config'] = default_config
else:
data['config'].setdefault('usd_to_kgs_rate', default_config['usd_to_kgs_rate'])
if 'usd_to_uzs_rate' in data['config']:
data['config']['usd_to_kgs_rate'] = data['config'].pop('usd_to_uzs_rate')
try:
data['config']['usd_to_kgs_rate'] = float(data['config']['usd_to_kgs_rate'])
except (ValueError, TypeError):
data['config']['usd_to_kgs_rate'] = default_config['usd_to_kgs_rate']
data['config'].setdefault('prices_enabled', default_config['prices_enabled'])
for prod in data.get('products', []):
# *** FIX: Data Integrity - Ensure every product has an ID before saving ***
if 'id' not in prod or not prod['id']:
prod['id'] = uuid.uuid4().hex
logging.warning(f"Product '{prod.get('name')}' was missing an ID during save. Assigned new ID: {prod['id']}")
prod.setdefault('supplier', 'Без поставщика')
if 'price' in prod and 'price_usd' not in prod: # Legacy field migration
try:
prod['price_usd'] = float(prod.pop('price'))
except (ValueError, TypeError):
prod['price_usd'] = 0.0
prod.setdefault('price_usd', 0.0)
try:
prod['price_usd'] = float(prod['price_usd'])
except (ValueError, TypeError):
prod['price_usd'] = 0.0
prod.setdefault('discount_percent', 0)
try:
prod['discount_percent'] = int(prod['discount_percent'])
if not (0 <= prod['discount_percent'] <= 100):
prod['discount_percent'] = 0
except (ValueError, TypeError):
prod['discount_percent'] = 0
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
logging.info(f"Data successfully saved to {DATA_FILE}")
upload_db_to_hf(specific_file=DATA_FILE)
except Exception as e:
logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
@app.context_processor
def inject_global_vars():
lang_translations = translations['ru']
app_config = load_data().get('config', {'usd_to_kgs_rate': 87.0, 'prices_enabled': True})
js_trans_keys = [
'error_product_not_found', 'error_invalid_quantity', 'error_adding_product_not_found',
'product_added_to_cart', 'cart_item_color_prefix', 'cart_item_color_suffix',
'remove_item_title', 'confirm_clear_cart', 'cart_empty_for_order', 'formulating_order',
'failed_to_create_order', 'order_id_not_received', 'error_generic',
'no_products_found_for_query', 'cart_empty', 'loading', 'no_products_yet',
'js_new_color_variant_placeholder', 'prices_disabled_notice_cart', 'price_on_request'
]
js_translations_dict = {key: lang_translations.get(key, key) for key in js_trans_keys}
return dict(
get_translation=get_translation,
js_translations=js_translations_dict,
app_config=app_config
)
CATALOG_TEMPLATE = '''