diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,4 +1,5 @@ + from flask import Flask, render_template_string, request, redirect, url_for, session, send_file, flash import json import os @@ -39,13 +40,14 @@ def load_data(): 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: data['products'] = [] if 'categories' not in data: data['categories'] = [] - # Ensure default values for new fields + # Ensure new fields exist with defaults for product in data['products']: product.setdefault('is_top', False) product.setdefault('in_stock', True) @@ -54,30 +56,22 @@ def load_data(): except FileNotFoundError: logging.warning(f"Локальный файл {DATA_FILE} не найден. Попытка скачать с HF.") try: - # Create empty file before downloading if it doesn't exist at all 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}") - - download_db_from_hf(specific_file=DATA_FILE) # Try downloading again - - # Check if download created the file or if it existed - if os.path.exists(DATA_FILE): + return {'products': [], 'categories': []} + else: with open(DATA_FILE, 'r', encoding='utf-8') as file: data = json.load(file) logging.info(f"Данные успешно загружены из {DATA_FILE} после попытки скачивания.") if not isinstance(data, dict): return {'products': [], 'categories': []} if 'products' not in data: data['products'] = [] if 'categories' not in data: data['categories'] = [] - # Ensure default values for new fields after download + # Ensure new fields exist with defaults after download for product in data['products']: product.setdefault('is_top', False) product.setdefault('in_stock', True) return data - else: - logging.warning(f"Файл {DATA_FILE} не найден и не скачан. Создание пустой структуры.") - return {'products': [], 'categories': []} # Return empty if download failed and file still missing - except (FileNotFoundError, RepositoryNotFoundError) as e: logging.warning(f"Файл {DATA_FILE} не найден локально и ошибка при доступе к репозиторию HF ({e}). Создание пустой структуры.") if not os.path.exists(DATA_FILE): @@ -87,7 +81,7 @@ def load_data(): logging.error(f"Ошибка декодирования JSON в {DATA_FILE} после попытки скачивания.") return {'products': [], 'categories': []} except Exception as e: - logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}", exc_info=True) + logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}") return {'products': [], 'categories': []} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.") @@ -96,11 +90,12 @@ def load_data(): logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True) return {'products': [], 'categories': []} + def save_data(data): try: - # Ensure defaults before saving + # Ensure new fields exist before saving if 'products' in data: - for product in data['products']: + for product in data['products']: product.setdefault('is_top', False) product.setdefault('in_stock', True) @@ -113,7 +108,6 @@ def save_data(data): def load_users(): 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}") @@ -121,22 +115,14 @@ def load_users(): except FileNotFoundError: logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.") try: - 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}") - download_db_from_hf(specific_file=USERS_FILE) # Attempt download again - if os.path.exists(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 {} - else: - logging.warning(f"Файл {USERS_FILE} не найден и не скачан. Возврат пустого словаря.") - return {} + 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, RepositoryNotFoundError): logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.") - if not os.path.exists(USERS_FILE): - with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) + with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) return {} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в {USERS_FILE} после скачивания.") @@ -151,7 +137,6 @@ def load_users(): logging.error(f"Неизвестная ошибка при загрузке пользователей ({USERS_FILE}): {e}", exc_info=True) return {} - def save_users(users): try: with open(USERS_FILE, 'w', encoding='utf-8') as file: @@ -161,6 +146,7 @@ def save_users(users): except Exception as e: logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}", exc_info=True) + def upload_db_to_hf(specific_file=None): if not HF_TOKEN_WRITE: logging.warning("Переменная окружения HF_TOKEN (для записи) не установлена. Загрузка на Hugging Face пропущена.") @@ -207,19 +193,16 @@ def download_db_from_hf(specific_file=None): token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - force_download=True # Force download to overwrite local file + force_download=True ) logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.") downloaded_files_count += 1 except RepositoryNotFoundError: logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.") - break # Stop trying if repo not found + break except Exception as e: - # Check specifically for file not found (different from repo not found) - if "404" in str(e) or "does not exist" in str(e).lower(): - logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропуск скачивания этого файла.") - # If the file doesn't exist remotely, should we delete locally? - # For now, just skip download. Local file remains as is or empty if created earlier. + if "404" in str(e) or isinstance(e, FileNotFoundError) or "EntryNotFound" in str(e): + logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.") else: logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True) logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.") @@ -230,7 +213,7 @@ def download_db_from_hf(specific_file=None): def periodic_backup(): - backup_interval = 1800 # 30 minutes + backup_interval = 1800 logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.") while True: time.sleep(backup_interval) @@ -246,15 +229,14 @@ def catalog(): categories = data.get('categories', []) is_authenticated = 'user' in session - # Add original index before filtering/sorting - for i, p in enumerate(all_products): - p['_original_index'] = i + # Filter out products that are not in stock + available_products = [p for p in all_products if p.get('in_stock', True)] - # Filter out "out of stock" products for display - display_products = [p for p in all_products if p.get('in_stock', True)] - - # Sort: Top products first, then by name - display_products.sort(key=lambda p: (not p.get('is_top', False), p.get('name', '').lower())) + # Sort products: top products first, then alphabetically + sorted_products = sorted( + available_products, + key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()) + ) catalog_html = ''' @@ -266,13 +248,229 @@ def catalog(): - - + - - - -
-
-

Soola Cosmetics

- - -
- -
Наш адрес: {{ store_address }}
- -
- - {% for category in categories %} - - {% endfor %} -
- -
- -
- -
- {% for product in display_products %} -
- {% if product.get('is_top') %} - Топ - {% endif %} -
- {% if product.get('photos') and product['photos']|length > 0 %} - {{ 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 %} -
-
- {% endfor %} - {% if not display_products %} -

Товары пока не добавлены или отсутствуют в наличии.

- {% endif %} -
-
- - - - - - - - - - - - - - -
- ''' return render_template_string( catalog_html, - display_products=display_products, # Pass the filtered/sorted list for display - all_products=all_products, # Pass the full list for JS + products=sorted_products, # Pass sorted and filtered products categories=categories, repo_id=REPO_ID, is_authenticated=is_authenticated, @@ -989,51 +915,62 @@ def catalog(): currency_code=CURRENCY_CODE ) + @app.route('/product/') def product_detail(index): data = load_data() - products = data.get('products', []) + all_products = data.get('products', []) is_authenticated = 'user' in session + + # Apply the same filtering and sorting as the catalog to ensure the index is correct + available_products = [p for p in all_products if p.get('in_stock', True)] + sorted_products = sorted( + available_products, + key=lambda p: (not p.get('is_top', False), p.get('name', '').lower()) + ) + try: - product = products[index] # Use original index + product = sorted_products[index] except IndexError: - logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}") - return "Товар не найден", 404 + logging.warning(f"Попытка доступа к несуществующему или недоступному продукту с индексом {index} (относительно видимого списка)") + return "Товар не найден или недоступен", 404 detail_html = '''

{{ product['name'] }}

-
+ {% if not product.get('in_stock', True) %} +

Нет в наличии

+ {% endif %} + {% if product.get('is_top') %} +

Популярный товар

+ {% endif %} +
{% if product.get('photos') and product['photos']|length > 0 %} {% for photo in product['photos'] %} -
+
- {{ product['name'] }} - фото {{ loop.index }} + style="max-width: 100%; max-height: 400px; object-fit: contain; display: block; margin: auto; cursor: grab;">
{% endfor %} {% else %} -
+
Изображение отсутствует
{% endif %}
{% if product.get('photos') and product['photos']|length > 1 %}
-
-
+
+
{% endif %}

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

- {% if not product.get('in_stock', True) %} -

Нет в наличии

- {% endif %} {% if is_authenticated %}

Цена: {{ "%.2f"|format(product['price']) }} {{ currency_code }}

{% else %} @@ -1045,19 +982,6 @@ def product_detail(index):

Доступные цвета/варианты: {{ colors|select('ne', '')|join(', ') }}

{% endif %}
- {% if is_authenticated %} -
- {% if product.get('in_stock', True) %} - - {% else %} - - {% endif %} -
- {% endif %}
''' return render_template_string( @@ -1065,11 +989,9 @@ def product_detail(index): product=product, repo_id=REPO_ID, is_authenticated=is_authenticated, - currency_code=CURRENCY_CODE, - index=index # Pass index needed for JS function call + currency_code=CURRENCY_CODE ) - LOGIN_TEMPLATE = ''' @@ -1178,14 +1100,8 @@ def auto_login(): logging.info(f"Автоматический вход для пользователя {login} выполнен.") return "OK", 200 else: - # If user exists in localStorage but not in users file, remove from localStorage - logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}. Удаление из localStorage.") - logout_response_html = ''' - - ''' - return f"Ошибка авто-входа{logout_response_html}", 400 # Send script within response body + logging.warning(f"Неудачная попытка автомати��еского входа для несуществующего пользователя {login}.") + return "Ошибка авто-входа", 400 @app.route('/logout') def logout(): @@ -1205,7 +1121,6 @@ def logout(): ''' return logout_response_html - ADMIN_TEMPLATE = ''' @@ -1222,7 +1137,7 @@ ADMIN_TEMPLATE = ''' h1, h2, h3 { font-weight: 600; color: #1C6758; margin-bottom: 15px; } h1 { font-size: 1.8rem; } h2 { font-size: 1.5rem; margin-top: 30px; display: flex; align-items: center; gap: 8px; } - h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; margin-bottom: 10px;} + h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; } .section { margin-bottom: 30px; padding: 20px; background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; } form { margin-bottom: 20px; } label { font-weight: 500; margin-top: 10px; display: block; color: #44524c; font-size: 0.9rem;} @@ -1231,6 +1146,8 @@ ADMIN_TEMPLATE = ''' textarea { min-height: 80px; resize: vertical; } input[type="file"] { padding: 8px; background-color: #f0f9f4; cursor: pointer; border: 1px solid #c4d9d1;} input[type="file"]::file-selector-button { padding: 5px 10px; border-radius: 4px; background-color: #e0f0e9; border: 1px solid #c4d9d1; cursor: pointer; margin-right: 10px;} + input[type="checkbox"] { margin-right: 5px; width: auto; vertical-align: middle;} + .checkbox-label { display: inline-block; margin-top: 10px; margin-bottom: 5px; font-weight: 400; color: #2d332f; } button, .button { padding: 10px 18px; border: none; border-radius: 6px; background-color: #1C6758; color: white; font-weight: 500; cursor: pointer; transition: background-color 0.3s ease, transform 0.1s ease; margin-top: 15px; font-size: 0.95rem; display: inline-flex; align-items: center; gap: 5px; text-decoration: none; line-height: 1.2;} button:hover, .button:hover { background-color: #164B41; } button:active, .button:active { transform: scale(0.98); } @@ -1244,39 +1161,39 @@ ADMIN_TEMPLATE = ''' .item p { margin: 5px 0; font-size: 0.9rem; color: #44524c; } .item strong { color: #2d332f; } .item .description { font-size: 0.85rem; color: #5e6e68; max-height: 60px; overflow: hidden; text-overflow: ellipsis; } + .item-status { display: flex; gap: 15px; flex-wrap: wrap; font-size: 0.85rem; margin-top: 8px;} + .status-badge { padding: 2px 8px; border-radius: 10px; font-weight: 500; } + .status-top { background-color: #ffecb3; color: #6d4c41; border: 1px solid #ffe082;} + .status-instock { background-color: #c8e6c9; color: #1b5e20; border: 1px solid #a5d6a7;} + .status-outofstock { background-color: #ffcdd2; color: #b71c1c; border: 1px solid #ef9a9a;} .item-actions { margin-top: 15px; display: flex; gap: 10px; flex-wrap: wrap; } .item-actions button:not(.delete-button) { background-color: #1C6758; } .item-actions button:not(.delete-button):hover { background-color: #164B41; } .edit-form-container { margin-top: 15px; padding: 20px; background: #f0f9f4; border: 1px dashed #c4d9d1; border-radius: 6px; display: none; } - details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; } - details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid transparent; /* Hide line when closed */ list-style: none; position: relative; transition: border-color 0.2s ease;} - details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #1C6758; } - details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } - details[open] > summary { border-bottom-color: #d1e7dd; /* Show line when open */ } - details .form-content { padding: 0 20px 20px 20px; /* Adjust padding */ } - .color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } - .color-input-group input { flex-grow: 1; margin: 0; } - .remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; } - .remove-color-btn:hover { background-color: #e53e3e; } - .add-color-btn { background-color: #63b3ed; } - .add-color-btn:hover { background-color: #4299e1; } - .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover; vertical-align: middle;} - .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } - .download-hf-button { background-color: #7a8d85; } - .download-hf-button:hover { background-color: #5e6e68; } - .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } - .flex-item { flex: 1; min-width: 350px; } - .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;} - .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} - .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} - .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } - .form-checkbox-group { display: flex; align-items: center; gap: 10px; margin-top: 10px; } - .form-checkbox-group input[type="checkbox"] { width: auto; margin-top: 0; } - .form-checkbox-group label { margin: 0; font-weight: normal; color: #44524c; cursor: pointer;} - .product-status { font-size: 0.85rem; padding: 2px 6px; border-radius: 4px; margin-left: 8px; display: inline-block; vertical-align: middle; } - .status-top { background-color: #ffc107; color: #333; } - .status-instock { background-color: #d4edda; color: #155724; } - .status-outstock { background-color: #f8d7da; color: #721c24; } + details { background-color: #f8fcfb; border: 1px solid #d1e7dd; border-radius: 8px; margin-bottom: 20px; } + details > summary { cursor: pointer; font-weight: 600; color: #164B41; display: block; padding: 15px; border-bottom: 1px solid #d1e7dd; list-style: none; position: relative; } + details > summary::after { content: '\\f078'; font-family: 'Font Awesome 6 Free'; font-weight: 900; position: absolute; right: 20px; top: 50%; transform: translateY(-50%); transition: transform 0.2s ease; color: #1C6758; } + details[open] > summary::after { transform: translateY(-50%) rotate(180deg); } + details[open] > summary { border-bottom: 1px solid #d1e7dd; } + details .form-content { padding: 20px; } + .color-input-group { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } + .color-input-group input { flex-grow: 1; margin: 0; } + .remove-color-btn { background-color: #f56565; padding: 6px 10px; font-size: 0.8rem; margin-top: 0; line-height: 1; } + .remove-color-btn:hover { background-color: #e53e3e; } + .add-color-btn { background-color: #63b3ed; } + .add-color-btn:hover { background-color: #4299e1; } + .photo-preview img { max-width: 70px; max-height: 70px; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #d1e7dd; object-fit: cover;} + .sync-buttons { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } + .download-hf-button { background-color: #7a8d85; } + .download-hf-button:hover { background-color: #5e6e68; } + .flex-container { display: flex; flex-wrap: wrap; gap: 20px; } + .flex-item { flex: 1; min-width: 350px; } + .message { padding: 10px 15px; border-radius: 6px; margin-bottom: 15px; font-size: 0.9rem;} + .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb;} + .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb;} + .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; } + .form-row { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 10px;} + .form-row > div { flex: 1; min-width: 150px;} @@ -1301,7 +1218,7 @@ ADMIN_TEMPLATE = '''
- +

Резервное копирование происходит автоматически каждые 30 минут, а также после каждого сохранения данных. Используйте эти кнопки для нем��дленной синхронизации.

@@ -1382,36 +1299,12 @@ ADMIN_TEMPLATE = '''

Телефон: {{ user_data.get('phone', 'Не указан') }}

Локация: {{ user_data.get('city', 'N/A') }}, {{ user_data.get('country', 'N/A') }}

- -
-
-

Редактирование: {{ login }}

-
- - - - - - - - - - - - - - - - - -
-
{% endfor %}
@@ -1445,6 +1338,16 @@ ADMIN_TEMPLATE = ''' +
+
+ + +
+
+ + +
+
@@ -1453,16 +1356,6 @@ ADMIN_TEMPLATE = '''
- -
- - -
-
- - -
-
@@ -1470,37 +1363,39 @@ ADMIN_TEMPLATE = '''

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

- {% if products %} + {% if all_products %}
- {% for product in products %} + {% for product in all_products %}
{% if product.get('photos') %} - Фото + Фото {% else %} Нет фото {% endif %}
-

- {{ product['name'] }} - {% if product.get('is_top') %} Топ{% endif %} - {% if product.get('in_stock', True) %} - В наличии - {% else %} - Нет в наличии - {% endif %} -

+

{{ product['name'] }}

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

Цена: {{ "%.2f"|format(product['price']) }} {{ currency_code }}

Описание: {{ product.get('description', 'N/A')[:150] }}{% if product.get('description', '')|length > 150 %}...{% endif %}

{% set colors = product.get('colors', []) %}

Цвета/Вар-ты: {{ colors|select('ne', '')|join(', ') if colors|select('ne', '')|list|length > 0 else 'Нет' }}

+
+ {% if product.get('is_top') %} + Топ товар + {% endif %} + {% if product.get('in_stock', True) %} + В наличии + {% else %} + Нет в наличии + {% endif %} +
{% if product.get('photos') and product['photos']|length > 1 %} -

(Всего фото: {{ product['photos']|length }})

+

(Всего фото: {{ product['photos']|length }})

{% endif %}
@@ -1509,7 +1404,7 @@ ADMIN_TEMPLATE = '''
- +
@@ -1518,7 +1413,7 @@ ADMIN_TEMPLATE = '''

Редактирование: {{ product['name'] }}

- + @@ -1538,16 +1433,26 @@ ADMIN_TEMPLATE = '''

Текущие фото:

{% for photo in product['photos'] %} - Фото {{ loop.index }} + Фото {{ loop.index }} {% endfor %}
{% endif %} +
+
+ + +
+
+ + +
+
{% set current_colors = product.get('colors', []) %} {% if current_colors and current_colors|select('ne', '')|list|length > 0 %} {% for color in current_colors %} - {% if color and color.strip() %} + {% if color.strip() %}
@@ -1562,16 +1467,6 @@ ADMIN_TEMPLATE = ''' {% endif %}
- -
- - -
-
- - -
-
@@ -1590,15 +1485,7 @@ ADMIN_TEMPLATE = ''' function toggleEditForm(formId) { const formContainer = document.getElementById(formId); if (formContainer) { - const isDisplayed = formContainer.style.display === 'block'; - // Close all other open edit forms first - document.querySelectorAll('.edit-form-container').forEach(form => { - if (form.id !== formId) { - form.style.display = 'none'; - } - }); - // Toggle the clicked form - formContainer.style.display = isDisplayed ? 'none' : 'block'; + formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none'; } } @@ -1623,19 +1510,14 @@ ADMIN_TEMPLATE = ''' const group = button.closest('.color-input-group'); if (group) { const container = group.parentNode; - // Only remove if it's not the last input in the container - if (container && container.querySelectorAll('.color-input-group').length > 1) { - group.remove(); - } else if (container && container.querySelectorAll('.color-input-group').length === 1) { - // If it's the last one, just clear the input value - const input = group.querySelector('input[name="colors"]'); - if (input) input.value = ''; - // Optionally show a message or prevent deletion of the last field - // alert("Cannot remove the last color input field."); - } else { - console.warn("Could not determine context for removing color input."); + // Prevent removing the last input field in the add form + const isAddForm = container.id === 'add-color-inputs'; + const siblingCount = container.querySelectorAll('.color-input-group').length; + if (isAddForm && siblingCount <= 1) { + group.querySelector('input[name="colors"]').value = ''; // Clear instead of removing + return; } - + group.remove(); } else { console.warn("Не удалось найти родительский .color-input-group для кнопки удаления"); } @@ -1647,16 +1529,16 @@ ADMIN_TEMPLATE = ''' @app.route('/admin', methods=['GET', 'POST']) def admin(): - # Ensure user is logged in to access admin - TODO: Add proper admin role check later - # if 'user' not in session: - # flash("Доступ запрещен. Пожалуйста, войдите.", "error") - # return redirect(url_for('login')) - data = load_data() - products = data.get('products', []) + # Admin sees ALL products, regardless of stock status + all_products = data.get('products', []) categories = data.get('categories', []) users = load_users() + # Sort products alphabetically for consistent admin display + all_products.sort(key=lambda x: x.get('name', '').lower()) + + if request.method == 'POST': action = request.form.get('action') logging.info(f"Admin action received: {action}") @@ -1675,14 +1557,15 @@ def admin(): flash("Название категории не может быть пустым.", 'error') else: logging.warning(f"Категория '{category_name}' уже существует.") - flash(f"Категория '{category_name}' уже существует.", 'warning') + flash(f"Категория '{category_name}' уже существует.", 'error') elif action == 'delete_category': category_to_delete = request.form.get('category_name') if category_to_delete and category_to_delete in categories: categories.remove(category_to_delete) updated_count = 0 - for product in products: + # Use all_products here as data['products'] is the source + for product in data['products']: if product.get('category') == category_to_delete: product['category'] = 'Без категории' updated_count += 1 @@ -1700,10 +1583,9 @@ def admin(): description = request.form.get('description', '').strip() category = request.form.get('category') photos_files = request.files.getlist('photos') - colors = [c.strip() for c in request.form.getlist('colors') if c and c.strip()] - is_top = 'is_top' in request.form - in_stock = 'in_stock' in request.form - + colors = [c.strip() for c in request.form.getlist('colors') if c.strip()] + is_top = request.form.get('is_top') == 'on' + in_stock = request.form.get('in_stock') == 'on' if not name or not price_str: flash("Название и цена товара обязательны.", 'error') @@ -1711,7 +1593,7 @@ def admin(): try: price = round(float(price_str), 2) - if price < 0: price = 0.0 + if price < 0: price = 0 except ValueError: flash("Неверный формат цены.", 'error') return redirect(url_for('admin')) @@ -1731,13 +1613,7 @@ def admin(): if photo and photo.filename: try: ext = os.path.splitext(photo.filename)[1].lower() - if ext not in ['.png', '.jpg', '.jpeg', '.webp', '.gif']: - logging.warning(f"Пропущен недопустимый тип файла фото: {photo.filename}") - flash(f"Пропущен файл {photo.filename} (недопустимый тип).", 'warning') - continue - - safe_name_part = "".join(c if c.isalnum() else "_" for c in name[:30]) - photo_filename = secure_filename(f"{safe_name_part}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") + photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...") @@ -1756,15 +1632,12 @@ def admin(): except Exception as e: logging.error(f"Ошибка загрузки фото {photo.filename} на HF: {e}", exc_info=True) flash(f"Ошибка при загрузке фото {photo.filename}.", 'error') - if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file on error elif photo and not photo.filename: logging.warning("Получен пустой объект файла фото при добавлении товара.") try: - # Check if directory is empty before removing + # Attempt to remove the temp dir only if it's empty if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) - elif os.path.exists(uploads_dir) and os.listdir(uploads_dir): - logging.warning(f"Временная папка {uploads_dir} не пуста после загрузки.") except OSError as e: logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}") @@ -1772,47 +1645,44 @@ def admin(): new_product = { 'name': name, 'price': price, 'description': description, 'category': category if category in categories else 'Без категории', - 'photos': photos_list, - 'colors': colors, - 'is_top': is_top, - 'in_stock': in_stock + 'photos': photos_list, 'colors': colors, + 'is_top': is_top, 'in_stock': in_stock } - products.append(new_product) - # Sort products after adding (optional, handled in catalog view now) - # products.sort(key=lambda x: (not x.get('is_top', False), x.get('name', '').lower())) + # Append to the source list in data + data['products'].append(new_product) + # No need to sort here, sorting happens when loading or displaying save_data(data) - logging.info(f"Товар '{name}' добавлен (Top: {is_top}, InStock: {in_stock}).") + logging.info(f"Товар '{name}' добавлен.") flash(f"Товар '{name}' успешно добавлен.", 'success') elif action == 'edit_product': - index_str = request.form.get('index') + index_str = request.form.get('original_index') # Use original_index from hidden input if index_str is None: flash("Ошибка редактирования: индекс товара не передан.", 'error') return redirect(url_for('admin')) try: + # Index refers to the position in the ORIGINAL all_products list from data index = int(index_str) - if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона") - product_to_edit = products[index] + if not (0 <= index < len(data['products'])): raise IndexError("Индекс вне диапазона") + product_to_edit = data['products'][index] original_name = product_to_edit.get('name', 'N/A') except (ValueError, IndexError) as e: - logging.error(f"Ошибка получения товара для редактирования по индексу {index_str}: {e}") - flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", 'error') + flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'. {e}", 'error') return redirect(url_for('admin')) product_to_edit['name'] = request.form.get('name', product_to_edit['name']).strip() price_str = request.form.get('price', str(product_to_edit['price'])).replace(',', '.') - product_to_edit['description'] = request.form.get('description', product_to_edit.get('description', '')).strip() + product_to_edit['description'] = request.form.get('description', product_to_edit['description']).strip() category = request.form.get('category') product_to_edit['category'] = category if category in categories else 'Без категории' - product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c and c.strip()] - product_to_edit['is_top'] = 'is_top' in request.form - product_to_edit['in_stock'] = 'in_stock' in request.form - + product_to_edit['colors'] = [c.strip() for c in request.form.getlist('colors') if c.strip()] + product_to_edit['is_top'] = request.form.get('is_top') == 'on' + product_to_edit['in_stock'] = request.form.get('in_stock') == 'on' try: price = round(float(price_str), 2) - if price < 0: price = 0.0 + if price < 0: price = 0 product_to_edit['price'] = price except ValueError: logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.") @@ -1835,13 +1705,7 @@ def admin(): if photo and photo.filename: try: ext = os.path.splitext(photo.filename)[1].lower() - if ext not in ['.png', '.jpg', '.jpeg', '.webp', '.gif']: - logging.warning(f"Пропущен недопустимый тип файла фото: {photo.filename}") - flash(f"Пропущен файл {photo.filename} (недопустимый тип).", 'warning') - continue - - safe_name_part = "".join(c if c.isalnum() else "_" for c in product_to_edit['name'][:30]) - photo_filename = secure_filename(f"{safe_name_part}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") + photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) logging.info(f"Загрузка нового фото {photo_filename} на HF...") @@ -1855,13 +1719,9 @@ def admin(): except Exception as e: logging.error(f"Ошибка загрузки нового фото {photo.filename}: {e}", exc_info=True) flash(f"Ошибка при загрузке нового фото {photo.filename}.", 'error') - if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file on error - try: if os.path.exists(uploads_dir) and not os.listdir(uploads_dir): os.rmdir(uploads_dir) - elif os.path.exists(uploads_dir) and os.listdir(uploads_dir): - logging.warning(f"Временная папка {uploads_dir} не пуста после загрузки.") except OSError as e: logging.warning(f"Не удалось удалить временную папку {uploads_dir}: {e}") @@ -1870,95 +1730,69 @@ def admin(): old_photos = product_to_edit.get('photos', []) if old_photos: logging.info(f"Попытка удаления старых фото: {old_photos}") - paths_to_delete = [f"photos/{p}" for p in old_photos if p] # Ensure no empty strings - if paths_to_delete: - try: - api.delete_files( - repo_id=REPO_ID, - paths_in_repo=paths_to_delete, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Delete old photos for product {product_to_edit['name']}", - # Consider adding ignore_patterns or allow_patterns if needed - # ignore_patterns=None, allow_patterns=None, - # Add timeout? - # timeout=None, - # Specify revision? Usually not needed for main branch - # revision=None, - ) - logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.") - except Exception as e: - # Check if it's a "not found" error which might be okay if file was already deleted - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"Некоторые старые фото {old_photos} не найдены на HF (возможно, уже удалены): {e}") - flash("Некоторые старые фото не были найдены на сервере (возможно, уже удалены).", "warning") - else: - logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True) - flash("Не удалось удалить старые фотографии с сервера.", "warning") - else: - logging.info("Нет действительных путей к старым фото для удаления.") - + try: + api.delete_files( + repo_id=REPO_ID, + paths_in_repo=[f"photos/{p}" for p in old_photos], + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Delete old photos for product {product_to_edit['name']}" + ) + logging.info(f"Старые фото для товара {product_to_edit['name']} удалены с HF.") + except Exception as e: + # Log error but don't stop the update process + logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True) + flash("Не удалось удалить старые фотографии с сервера.", "warning") product_to_edit['photos'] = new_photos_list flash("Фотографии товара успешно обновлены.", "success") elif uploaded_count == 0 and any(f.filename for f in photos_files): - # This case means files were selected, but none were uploaded (e.g., all failed or wrong type) - flash("Не удалось загрузить новые фотографии (возможно, из-за ошибки или неверного формата). Старые фото сохранены.", "error") - # If no new files were selected, product_to_edit['photos'] remains unchanged + flash("Не удалось загрузить новые фотографии.", "error") - # Sort products after edit (optional, handled in catalog view now) - # products.sort(key=lambda x: (not x.get('is_top', False), x.get('name', '').lower())) + # No sorting needed here, save directly save_data(data) - logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}' (Top: {product_to_edit['is_top']}, InStock: {product_to_edit['in_stock']}).") + logging.info(f"Товар '{original_name}' (исходный индекс {index}) обновлен на '{product_to_edit['name']}'.") flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success') + elif action == 'delete_product': - index_str = request.form.get('index') + index_str = request.form.get('original_index') # Use original_index if index_str is None: flash("Ошибка удаления: индекс товара не передан.", 'error') return redirect(url_for('admin')) try: index = int(index_str) - if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона") - deleted_product = products.pop(index) + # Index refers to the position in the ORIGINAL all_products list from data + if not (0 <= index < len(data['products'])): raise IndexError("Индекс вне диапазона") + deleted_product = data['products'].pop(index) # Remove from the source list product_name = deleted_product.get('name', 'N/A') photos_to_delete = deleted_product.get('photos', []) if photos_to_delete and HF_TOKEN_WRITE: logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}") - paths_to_delete = [f"photos/{p}" for p in photos_to_delete if p] - if paths_to_delete: - try: - api = HfApi() - api.delete_files( - repo_id=REPO_ID, - paths_in_repo=paths_to_delete, - repo_type="dataset", - token=HF_TOKEN_WRITE, - commit_message=f"Delete photos for deleted product {product_name}" - ) - logging.info(f"Фото товара '{product_name}' удалены с HF.") - except Exception as e: - if "404" in str(e) or "not found" in str(e).lower(): - logging.warning(f"Некоторые фото {photos_to_delete} для удаляемого товара '{product_name}' не найдены на HF (возможно, уже удалены): {e}") - # Don't flash error to user in this case, as product is being deleted anyway - else: - logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True) - flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning") - else: - logging.info(f"Нет действительных путей к фото для удаления для товара '{product_name}'.") - + try: + api = HfApi() + api.delete_files( + repo_id=REPO_ID, + paths_in_repo=[f"photos/{p}" for p in photos_to_delete], + repo_type="dataset", + token=HF_TOKEN_WRITE, + commit_message=f"Delete photos for deleted product {product_name}" + ) + logging.info(f"Фото товара '{product_name}' удалены с HF.") + except Exception as e: + logging.error(f"Ошибка при удалении фото {photos_to_delete} для товара '{product_name}' с HF: {e}", exc_info=True) + flash(f"Не удалось удалить фото для товара '{product_name}' с сервера.", "warning") save_data(data) - logging.info(f"Товар '{product_name}' (индекс {index}) удален.") + logging.info(f"Товар '{product_name}' (исходный индекс {index}) удален.") flash(f"Товар '{product_name}' удален.", 'success') except (ValueError, IndexError) as e: - logging.error(f"Ошибка при удалении товара по индексу {index_str}: {e}") - flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error') + flash(f"Ошибка удаления: неверный индекс товара '{index_str}'. {e}", 'error') elif action == 'add_user': login = request.form.get('login', '').strip() - password = request.form.get('password', '').strip() # Keep password handling simple for now + password = request.form.get('password', '').strip() first_name = request.form.get('first_name', '').strip() last_name = request.form.get('last_name', '').strip() country = request.form.get('country', '').strip() @@ -1973,7 +1807,7 @@ def admin(): return redirect(url_for('admin')) users[login] = { - 'password': password, # Store plain text password + 'password': password, 'first_name': first_name, 'last_name': last_name, 'country': country, 'city': city, 'phone': phone @@ -1982,55 +1816,6 @@ def admin(): logging.info(f"Пользователь '{login}' добавлен.") flash(f"Пользователь '{login}' успешно добавлен.", 'success') - elif action == 'edit_user': - original_login = request.form.get('original_login') - new_login = request.form.get('login', '').strip() - new_password = request.form.get('password', '').strip() - first_name = request.form.get('first_name', '').strip() - last_name = request.form.get('last_name', '').strip() - country = request.form.get('country', '').strip() - city = request.form.get('city', '').strip() - phone = request.form.get('phone', '').strip() - - if not original_login: - flash("Ошибка редактирования: оригинальный логин не найден.", 'error') - return redirect(url_for('admin')) - if not new_login: - flash("Логин пользователя не может быть пустым.", 'error') - return redirect(url_for('admin')) - if original_login not in users: - flash(f"Пользователь с логином '{original_login}' не найден для редактирования.", 'error') - return redirect(url_for('admin')) - if new_login != original_login and new_login in users: - flash(f"Новый логин '{new_login}' уже занят другим пользователем.", 'error') - return redirect(url_for('admin')) - - # Get current user data - user_data = users[original_login] - - # Update fields - user_data['first_name'] = first_name - user_data['last_name'] = last_name - user_data['country'] = country - user_data['city'] = city - user_data['phone'] = phone - if new_password: # Update password only if a new one is provided - user_data['password'] = new_password - - # Handle login change (remove old, add new) - if new_login != original_login: - users[new_login] = user_data - del users[original_login] - logging.info(f"Логин пользователя '{original_login}' изменен на '{new_login}'.") - else: - # If login didn't change, just update the data in place - users[original_login] = user_data - - save_users(users) - logging.info(f"Данные пользователя '{new_login}' обновлены.") - flash(f"Данные пользователя '{new_login}' успешно обновлены.", 'success') - - elif action == 'delete_user': login_to_delete = request.form.get('login') if login_to_delete and login_to_delete in users: @@ -2053,14 +1838,18 @@ def admin(): flash(f"Произошла внутренняя ошибка при выполнении действия '{action}'. Подробности в логе сервера.", 'error') return redirect(url_for('admin')) - # Sort products for admin display (keeps admin view consistent) - products.sort(key=lambda x: x.get('name', '').lower()) + # Prepare data for rendering the admin page again after potential POST redirect or GET + data = load_data() # Reload data in case it was modified + all_products = data.get('products', []) + all_products.sort(key=lambda x: x.get('name', '').lower()) # Sort for display + categories = data.get('categories', []) categories.sort() + users = load_users() sorted_users = dict(sorted(users.items())) return render_template_string( ADMIN_TEMPLATE, - products=products, # Pass full list to admin + all_products=all_products, # Pass all products to admin template categories=categories, users=sorted_users, repo_id=REPO_ID, @@ -2087,7 +1876,6 @@ def force_download(): except Exception as e: logging.error(f"Ошибка при принудительном скачивании: {e}", exc_info=True) flash(f"Ошибка при скачивании с Hugging Face: {e}", 'error') - # No need to reload data here, it will be reloaded on the next request (e.g., redirect to admin) return redirect(url_for('admin')) @@ -2101,9 +1889,14 @@ if __name__ == '__main__': backup_thread.start() logging.info("Поток периодического резервного копирования запущен.") else: - logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN не установлена).") + logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).") port = int(os.environ.get('PORT', 7860)) logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}") - # Disable debug mode for production/deployment + # Use Waitress or Gunicorn for production instead of app.run(debug=...) + # Example with Waitress (install waitress first: pip install waitress) + # from waitress import serve + # serve(app, host='0.0.0.0', port=port) + # For development: app.run(debug=False, host='0.0.0.0', port=port) +