diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -44,22 +44,40 @@ def load_data(): data['products'] = [] if 'categories' not in data: data['categories'] = [] + + # Ensure default values for new fields + for product in data['products']: + product.setdefault('is_top', False) + product.setdefault('in_stock', True) + return 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}") - return {'products': [], 'categories': []} - else: + + 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): 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 + 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): @@ -69,7 +87,7 @@ def load_data(): logging.error(f"Ошибка декодирования JSON в {DATA_FILE} после попытки скачивания.") return {'products': [], 'categories': []} except Exception as e: - logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}") + logging.error(f"Неизвестная ошибка при загрузке данных после попытки скачивания: {e}", exc_info=True) return {'products': [], 'categories': []} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в локальном {DATA_FILE}. Файл может быть поврежден. Возврат пустой структуры.") @@ -78,9 +96,14 @@ def load_data(): logging.error(f"Неизвестная ошибка при загрузке данных ({DATA_FILE}): {e}", exc_info=True) return {'products': [], 'categories': []} - def save_data(data): try: + # Ensure defaults before saving + if 'products' in data: + for product in data['products']: + product.setdefault('is_top', False) + product.setdefault('in_stock', True) + with open(DATA_FILE, 'w', encoding='utf-8') as file: json.dump(data, file, ensure_ascii=False, indent=4) logging.info(f"Данные успешно сохранены в {DATA_FILE}") @@ -90,6 +113,7 @@ 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}") @@ -97,14 +121,22 @@ def load_users(): except FileNotFoundError: logging.warning(f"Локальный файл {USERS_FILE} не найден. Попытка скачать с HF.") 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 {} + 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 {} except (FileNotFoundError, RepositoryNotFoundError): logging.warning(f"Файл {USERS_FILE} не найден локально и в репозитории HF. Создание пустого файла.") - with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) + if not os.path.exists(USERS_FILE): + with open(USERS_FILE, 'w', encoding='utf-8') as f: json.dump({}, f) return {} except json.JSONDecodeError: logging.error(f"Ошибка декодирования JSON в {USERS_FILE} после скачивания.") @@ -119,6 +151,7 @@ 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: @@ -128,7 +161,6 @@ 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 пропущена.") @@ -175,16 +207,19 @@ def download_db_from_hf(specific_file=None): token=HF_TOKEN_READ, local_dir=".", local_dir_use_symlinks=False, - force_download=True + force_download=True # Force download to overwrite local file ) logging.info(f"Файл {file_name} успешно скачан из Hugging Face в {local_path}.") downloaded_files_count += 1 except RepositoryNotFoundError: logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание прервано.") - break + break # Stop trying if repo not found except Exception as e: - if "404" in str(e) or isinstance(e, FileNotFoundError): - logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID} или ошибка доступа. Пропуск скачивания этого файла.") + # 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. else: logging.error(f"Ошибка при скачивании файла {file_name} с Hugging Face: {e}", exc_info=True) logging.info(f"Скачивание файлов с HF завершено. Скачано файлов: {downloaded_files_count}/{len(files_to_download)}.") @@ -195,7 +230,7 @@ def download_db_from_hf(specific_file=None): def periodic_backup(): - backup_interval = 1800 + backup_interval = 1800 # 30 minutes logging.info(f"Настройка периодического резервного копирования каждые {backup_interval} секунд.") while True: time.sleep(backup_interval) @@ -207,10 +242,20 @@ def periodic_backup(): @app.route('/') def catalog(): data = load_data() - products = data.get('products', []) + all_products = data.get('products', []) 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 "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())) + catalog_html = ''' @@ -221,225 +266,13 @@ def catalog(): - - - -
-
-

Soola Cosmetics

- - -
- -
Наш адрес: {{ store_address }}
- -
- - {% for category in categories %} - - {% endfor %} -
- -
- -
- -
- {% for product in products %} -
-
- {% 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', '')[:50] }}{% if product.get('description', '')|length > 50 %}...{% endif %}

-
-
- - {% if is_authenticated %} - - {% endif %} -
-
- {% endfor %} - {% if not products %} -

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

- {% endif %} -
-
- - - - - - - - - -
- - - + + + + +
+
+

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, - products=products, + display_products=display_products, # Pass the filtered/sorted list for display + all_products=all_products, # Pass the full list for JS categories=categories, repo_id=REPO_ID, is_authenticated=is_authenticated, @@ -841,14 +989,13 @@ def catalog(): currency_code=CURRENCY_CODE ) - @app.route('/product/') def product_detail(index): data = load_data() products = data.get('products', []) is_authenticated = 'user' in session try: - product = products[index] + product = products[index] # Use original index except IndexError: logging.warning(f"Попытка доступа к несуществующему продукту с индексом {index}") return "Товар не найден", 404 @@ -856,33 +1003,37 @@ def product_detail(index): detail_html = '''

{{ product['name'] }}

-
+
{% 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;" + onerror="this.onerror=null; this.src='https://via.placeholder.com/400x400.png?text=Load+Error';">
{% 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 %} @@ -894,6 +1045,19 @@ 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( @@ -901,9 +1065,11 @@ def product_detail(index): product=product, repo_id=REPO_ID, is_authenticated=is_authenticated, - currency_code=CURRENCY_CODE + currency_code=CURRENCY_CODE, + index=index # Pass index needed for JS function call ) + LOGIN_TEMPLATE = ''' @@ -964,7 +1130,7 @@ def login(): 'last_name': user_info.get('last_name', ''), 'country': user_info.get('country', ''), 'city': user_info.get('city', ''), - 'phone': user_info.get('phone', '') # Added phone + 'phone': user_info.get('phone', '') } logging.info(f"Пользователь {login} успешно вошел в систему.") login_response_html = f''' @@ -1007,13 +1173,19 @@ def auto_login(): 'last_name': user_info.get('last_name', ''), 'country': user_info.get('country', ''), 'city': user_info.get('city', ''), - 'phone': user_info.get('phone', '') # Added phone + 'phone': user_info.get('phone', '') } logging.info(f"Автоматический вход для пользователя {login} выполнен.") return "OK", 200 else: - logging.warning(f"Неудачная попытка автоматического входа для несуществующего пользователя {login}.") - return "Ошибка авто-входа", 400 + # 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 @app.route('/logout') def logout(): @@ -1033,6 +1205,7 @@ def logout(): ''' return logout_response_html + ADMIN_TEMPLATE = ''' @@ -1049,7 +1222,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; } + h3 { font-size: 1.2rem; color: #164B41; margin-top: 20px; margin-bottom: 10px;} .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;} @@ -1075,28 +1248,35 @@ ADMIN_TEMPLATE = ''' .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 #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; } + 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; } @@ -1121,7 +1301,7 @@ ADMIN_TEMPLATE = '''
- +

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

@@ -1202,12 +1382,35 @@ ADMIN_TEMPLATE = '''

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

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

+ +
- +
+
+

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

+
+ + + + + + + + + + + + + + + + + +
{% endfor %} @@ -1250,6 +1453,16 @@ ADMIN_TEMPLATE = '''
+ +
+ + +
+
+ + +
+
@@ -1265,14 +1478,22 @@ ADMIN_TEMPLATE = '''
{% if product.get('photos') %} - Фото + Фото {% else %} Нет фото {% endif %}
-

{{ product['name'] }}

+

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

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

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

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

@@ -1317,7 +1538,7 @@ ADMIN_TEMPLATE = '''

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

{% for photo in product['photos'] %} - Фото {{ loop.index }} + Фото {{ loop.index }} {% endfor %}
{% endif %} @@ -1326,7 +1547,7 @@ ADMIN_TEMPLATE = ''' {% set current_colors = product.get('colors', []) %} {% if current_colors and current_colors|select('ne', '')|list|length > 0 %} {% for color in current_colors %} - {% if color.strip() %} + {% if color and color.strip() %}
@@ -1341,6 +1562,16 @@ ADMIN_TEMPLATE = ''' {% endif %}
+ +
+ + +
+
+ + +
+
@@ -1359,7 +1590,15 @@ ADMIN_TEMPLATE = ''' function toggleEditForm(formId) { const formContainer = document.getElementById(formId); if (formContainer) { - formContainer.style.display = formContainer.style.display === 'none' || formContainer.style.display === '' ? 'block' : 'none'; + 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'; } } @@ -1384,7 +1623,19 @@ ADMIN_TEMPLATE = ''' const group = button.closest('.color-input-group'); if (group) { const container = group.parentNode; - group.remove(); + // 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."); + } + } else { console.warn("Не удалось найти родительский .color-input-group для кнопки удаления"); } @@ -1396,6 +1647,11 @@ 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', []) categories = data.get('categories', []) @@ -1419,7 +1675,7 @@ def admin(): flash("Название категории не может быть пустым.", 'error') else: logging.warning(f"Категория '{category_name}' уже существует.") - flash(f"Категория '{category_name}' уже существует.", 'error') + flash(f"Категория '{category_name}' уже существует.", 'warning') elif action == 'delete_category': category_to_delete = request.form.get('category_name') @@ -1444,7 +1700,10 @@ 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.strip()] + 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 + if not name or not price_str: flash("Название и цена товара обязательны.", 'error') @@ -1452,13 +1711,13 @@ def admin(): try: price = round(float(price_str), 2) - if price < 0: price = 0 + if price < 0: price = 0.0 except ValueError: flash("Неверный формат цены.", 'error') return redirect(url_for('admin')) photos_list = [] - if photos_files and HF_TOKEN_WRITE: + if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE: uploads_dir = 'uploads_temp' os.makedirs(uploads_dir, exist_ok=True) api = HfApi() @@ -1471,8 +1730,14 @@ def admin(): break if photo and photo.filename: try: - ext = os.path.splitext(photo.filename)[1] - photo_filename = secure_filename(f"{name.replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") + 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}") temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) logging.info(f"Загрузка фото {photo_filename} на HF для товара {name}...") @@ -1491,11 +1756,15 @@ 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: - if not os.listdir(uploads_dir): + # Check if directory is empty before removing + 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}") @@ -1503,12 +1772,16 @@ def admin(): new_product = { 'name': name, 'price': price, 'description': description, 'category': category if category in categories else 'Без категории', - 'photos': photos_list, 'colors': colors + 'photos': photos_list, + 'colors': colors, + 'is_top': is_top, + 'in_stock': in_stock } products.append(new_product) - products.sort(key=lambda x: x.get('name', '').lower()) + # 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())) save_data(data) - logging.info(f"Товар '{name}' добавлен.") + logging.info(f"Товар '{name}' добавлен (Top: {is_top}, InStock: {in_stock}).") flash(f"Товар '{name}' успешно добавлен.", 'success') elif action == 'edit_product': @@ -1522,20 +1795,24 @@ def admin(): if not (0 <= index < len(products)): raise IndexError("Индекс вне диапазона") product_to_edit = products[index] original_name = product_to_edit.get('name', 'N/A') - except (ValueError, IndexError): + except (ValueError, IndexError) as e: + logging.error(f"Ошибка получения товара для редактирования по индексу {index_str}: {e}") flash(f"Ошибка редактирования: неверный индекс товара '{index_str}'.", '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['description']).strip() + product_to_edit['description'] = request.form.get('description', product_to_edit.get('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.strip()] + 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 + try: price = round(float(price_str), 2) - if price < 0: price = 0 + if price < 0: price = 0.0 product_to_edit['price'] = price except ValueError: logging.warning(f"Неверный формат цены '{price_str}' при редактировании товара {original_name}. Цена не изменена.") @@ -1557,8 +1834,14 @@ def admin(): break if photo and photo.filename: try: - ext = os.path.splitext(photo.filename)[1] - photo_filename = secure_filename(f"{product_to_edit['name'].replace(' ','_')}_{datetime.now().strftime('%Y%m%d%H%M%S%f')}{ext}") + 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}") temp_path = os.path.join(uploads_dir, photo_filename) photo.save(temp_path) logging.info(f"Загрузка нового фото {photo_filename} на HF...") @@ -1572,9 +1855,13 @@ 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 not os.listdir(uploads_dir): + 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}") @@ -1583,29 +1870,47 @@ def admin(): old_photos = product_to_edit.get('photos', []) if old_photos: logging.info(f"Попытка удаления старых фото: {old_photos}") - 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: - logging.error(f"Ошибка при удалении старых фото {old_photos} с HF: {e}", exc_info=True) - flash("Не удалось удалить старые фотографии с сервера.", "warning") + 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("Нет действительных путей к старым фото для удаления.") + product_to_edit['photos'] = new_photos_list flash("Фотографии товара успешно обновлены.", "success") elif uploaded_count == 0 and any(f.filename for f in photos_files): - flash("Не удалось загрузить новые фотографии.", "error") + # 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 - products.sort(key=lambda x: x.get('name', '').lower()) + # 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())) save_data(data) - logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}'.") + logging.info(f"Товар '{original_name}' (индекс {index}) обновлен на '{product_to_edit['name']}' (Top: {product_to_edit['is_top']}, InStock: {product_to_edit['in_stock']}).") flash(f"Товар '{product_to_edit['name']}' успешно обновлен.", 'success') - elif action == 'delete_product': index_str = request.form.get('index') if index_str is None: @@ -1620,35 +1925,45 @@ def admin(): photos_to_delete = deleted_product.get('photos', []) if photos_to_delete and HF_TOKEN_WRITE: logging.info(f"Попытка удаления фото товара '{product_name}' с HF: {photos_to_delete}") - 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") + 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}'.") + save_data(data) logging.info(f"Товар '{product_name}' (индекс {index}) удален.") flash(f"Товар '{product_name}' удален.", 'success') - except (ValueError, IndexError): + except (ValueError, IndexError) as e: + logging.error(f"Ошибка при удалении товара по индексу {index_str}: {e}") flash(f"Ошибка удаления: неверный индекс товара '{index_str}'.", 'error') elif action == 'add_user': login = request.form.get('login', '').strip() - password = request.form.get('password', '').strip() + password = request.form.get('password', '').strip() # Keep password handling simple for now 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() # Added phone + phone = request.form.get('phone', '').strip() if not login or not password: flash("Логин и пароль пользователя обязательны.", 'error') @@ -1658,15 +1973,64 @@ def admin(): return redirect(url_for('admin')) users[login] = { - 'password': password, + 'password': password, # Store plain text password 'first_name': first_name, 'last_name': last_name, 'country': country, 'city': city, - 'phone': phone # Added phone + 'phone': phone } save_users(users) 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: @@ -1689,13 +2053,14 @@ 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()) categories.sort() sorted_users = dict(sorted(users.items())) return render_template_string( ADMIN_TEMPLATE, - products=products, + products=products, # Pass full list to admin categories=categories, users=sorted_users, repo_id=REPO_ID, @@ -1722,10 +2087,12 @@ 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')) if __name__ == '__main__': + # Initial load on startup load_data() load_users() @@ -1734,8 +2101,9 @@ if __name__ == '__main__': backup_thread.start() logging.info("Поток периодического резервного копирования запущен.") else: - logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN или HF_TOKEN_WRITE не установлена).") + logging.warning("Периодическое резервное копирование НЕ будет запущено (HF_TOKEN не установлена).") port = int(os.environ.get('PORT', 7860)) logging.info(f"Запуск Flask приложения на хосте 0.0.0.0 и порту {port}") + # Disable debug mode for production/deployment app.run(debug=False, host='0.0.0.0', port=port)