Testapi / app.py
Shveiauto's picture
Update app.py
cacdb5c verified
raw
history blame
105 kB
from flask import Flask, render_template_string, request, redirect, url_for, session, send_from_directory
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
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.secret_key = 'your_unique_secret_key_12345' # Уникальный секретный ключ (Лучше сменить на более сложный)
DATA_FILE = 'data_soola.json'
USERS_FILE = 'users_soola.json'
# Список файлов для синхронизации
SYNC_FILES = [DATA_FILE, USERS_FILE]
# Настройки Hugging Face
REPO_ID = "Kgshop/Soola" # Рекомендуется сменить на репозиторий для Soola Cosmetics
HF_TOKEN_WRITE = os.getenv("HF_TOKEN")
HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
# Адрес магазина
STORE_ADDRESS = "Рынок Дордой, Джунхай, терминал, 38"
# Валюта (только сом)
CURRENCY_SYMBOL = 'с'
CURRENCY_CODE = 'KGS'
# Настройка логирования
logging.basicConfig(level=logging.DEBUG)
def load_data():
"""Загрузка данных о товарах и категориях."""
try:
# Попытка скачать актуальные данные перед загрузкой
try:
download_db_from_hf()
except Exception as e:
logging.warning(f"Не удалось скачать данные с Hugging Face при запуске: {e}. Используется локальная копия.")
with open(DATA_FILE, 'r', encoding='utf-8') as file:
data = json.load(file)
logging.info("Данные успешно загружены из JSON")
# Проверка структуры данных
if not isinstance(data, dict):
# Если старый формат (просто список продуктов), преобразуем
if isinstance(data, list):
return {'products': data, 'categories': []}
else:
return {'products': [], 'categories': []}
if 'products' not in data:
data['products'] = []
if 'categories' not in data:
data['categories'] = []
return data
except FileNotFoundError:
logging.warning(f"{DATA_FILE} не найден. Создание пустой структуры данных.")
return {'products': [], 'categories': []}
except json.JSONDecodeError:
logging.error(f"Ошибка: Невозможно декодировать JSON файл {DATA_FILE}.")
return {'products': [], 'categories': []}
except Exception as e:
logging.error(f"Произошла непредвиденная ошибка при загрузке данных: {e}")
return {'products': [], 'categories': []}
def save_data(data):
"""Сохранение данных о товарах и категориях."""
try:
with open(DATA_FILE, 'w', encoding='utf-8') as file:
json.dump(data, file, ensure_ascii=False, indent=4)
logging.info(f"Данные успешно сохранены в {DATA_FILE}")
# Загружаем на HF после сохранения
upload_db_to_hf(DATA_FILE)
except Exception as e:
logging.error(f"Ошибка при сохранении данных в {DATA_FILE}: {e}")
# Не прерываем работу приложения, но логируем ошибку
# raise # Можно раскомментировать, если критично прерывать работу при ошибке сохранения
def load_users():
"""Загрузка данных пользователей."""
try:
# Попытка скачать актуальные данные перед загрузкой
try:
download_db_from_hf()
except Exception as e:
logging.warning(f"Не удалось скачать данные пользователей с Hugging Face при запуске: {e}. Используется локальная копия.")
with open(USERS_FILE, 'r', encoding='utf-8') as file:
users = json.load(file)
logging.info("Данные пользователей успешно загружены")
return users if isinstance(users, dict) else {}
except FileNotFoundError:
logging.warning(f"{USERS_FILE} не найден. Возвращен пустой словарь пользователей.")
return {}
except json.JSONDecodeError:
logging.error(f"Ошибка: Невозможно декодировать JSON файл {USERS_FILE}.")
return {}
except Exception as e:
logging.error(f"Произошла непредвиденная ошибка при загрузке пользователей: {e}")
return {}
def save_users(users):
"""Сохранение данных пользователей."""
try:
with open(USERS_FILE, 'w', encoding='utf-8') as file:
json.dump(users, file, ensure_ascii=False, indent=4)
logging.info(f"Данные пользователей успешно сохранены в {USERS_FILE}")
# Загружаем на HF после сохранения
upload_db_to_hf(USERS_FILE)
except Exception as e:
logging.error(f"Ошибка при сохранении данных пользователей в {USERS_FILE}: {e}")
def upload_db_to_hf(filename_to_upload=None):
"""Загрузка файлов на Hugging Face. Если filename_to_upload не указан, загружает все файлы из SYNC_FILES."""
if not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN (токен для записи) не установлен. Загрузка на Hugging Face отключена.")
return
try:
api = HfApi()
files_to_sync = [filename_to_upload] if filename_to_upload else SYNC_FILES
for file_name in files_to_sync:
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"Автоматическое резервное копирование {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
logging.info(f"Резервная копия {file_name} успешно загружена на Hugging Face.")
except Exception as e:
logging.error(f"Ошибка при загрузке файла {file_name} на Hugging Face: {e}")
else:
logging.warning(f"Файл {file_name} не найден для загрузки на Hugging Face.")
except Exception as e:
logging.error(f"Общая ошибка при инициализации или загрузке на Hugging Face: {e}")
def download_db_from_hf():
"""Скачивание файлов с Hugging Face."""
if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN_READ и HF_TOKEN (токен для чтения/записи) не установлены. Скачивание с Hugging Face отключено.")
return
# Используем токен для записи, если токен для чтения не задан
token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
try:
api = HfApi() # Api() не используется для скачивания, но можно оставить для единообразия
for file_name in SYNC_FILES:
try:
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 # Принудительно скачивать, чтобы получить свежую версию
)
logging.info(f"Файл {file_name} успешно скачан из Hugging Face.")
except RepositoryNotFoundError:
logging.error(f"Репозиторий {REPO_ID} не найден на Hugging Face. Скачивание {file_name} невозможно.")
# Не прерываем скачивание остальных файлов
except Exception as e: # Ловим более конкретные ошибки по файлам
# Проверяем, является ли ошибка 'Not Found' (файл не существует в репо)
if "404 Client Error" in str(e) or "EntryNotFoundError" in str(e):
logging.warning(f"Файл {file_name} не найден в репозитории {REPO_ID}. Пропускаем скачивание.")
else:
logging.error(f"Ошибка при скачивании файла {file_name} из Hugging Face: {e}")
except Exception as e: # Общая ошибка на уровне всего процесса скачивания
logging.error(f"Общая ошибка при скачивании файлов с Hugging Face: {e}")
# Не прерываем работу приложения, но логируем
# raise # Можно раскомментировать, если критично прервать работу при ошибке скачивания
def periodic_backup():
"""Периодическая загрузка всех файлов на Hugging Face."""
while True:
time.sleep(800) # 13 минут 20 секунд
logging.info("Запуск периодического резервного копирования...")
upload_db_to_hf() # Загружаем все файлы
@app.route('/')
def catalog():
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
is_authenticated = 'user' in session
catalog_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Soola Cosmetics - Каталог</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #f8f0f5, #f0e9ef); /* Немного косметические цвета */
color: #4a3f4d; /* Темно-пурпурный для текста */
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
body.dark-mode {
background: linear-gradient(135deg, #2d1a2c, #482d47); /* Темные косметические */
color: #e2d8e0;
}
.container {
max-width: 1300px;
margin: 0 auto;
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #e2d8e0;
}
body.dark-mode .header { border-bottom: 1px solid #4a3f4d; }
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #8a4d80; /* Пурпурный акцент */
}
body.dark-mode .header h1 { color: #e2d8e0; }
.auth-links {
display: flex;
gap: 15px;
align-items: center;
}
.auth-links a {
color: #c864c8; /* Яркий пурпурный для ссылок */
text-decoration: none;
font-weight: 500;
}
.auth-links a:hover {
text-decoration: underline;
}
.auth-links span {
font-weight: 500;
}
.theme-toggle {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6a5f6d; /* Серый пурпурный */
transition: color 0.3s ease;
}
body.dark-mode .theme-toggle { color: #b2a8b0; }
.theme-toggle:hover {
color: #c864c8;
}
.filters-container {
margin: 20px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.search-container {
margin: 20px 0;
text-align: center;
}
#search-input {
width: 90%;
max-width: 600px;
padding: 12px 18px;
font-size: 1rem;
border: 1px solid #e2d8e0;
border-radius: 20px; /* Более круглые края */
outline: none;
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
transition: all 0.3s ease;
background-color: #fff;
}
body.dark-mode #search-input { background-color: #4a3f4d; border-color: #6a5f6d; color: #e2d8e0; }
#search-input:focus {
border-color: #c864c8;
box-shadow: 0 4px 15px rgba(200, 100, 200, 0.2);
}
.category-filter {
padding: 8px 16px;
border: 1px solid #e2d8e0;
border-radius: 20px;
background-color: #fff;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 0.9rem;
font-weight: 400;
}
body.dark-mode .category-filter { background-color: #4a3f4d; border-color: #6a5f6d; color: #e2d8e0; }
.category-filter.active, .category-filter:hover {
background-color: #c864c8;
color: white;
border-color: #c864c8;
box-shadow: 0 2px 10px rgba(200, 100, 200, 0.3);
}
.products-grid {
display: grid;
/* Адаптивные колонки */
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px; /* Немного больше отступ */
padding: 10px;
}
@media (min-width: 768px) {
.products-grid { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
}
@media (min-width: 1024px) {
.products-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
}
.product {
background: #fff;
border-radius: 15px;
padding: 15px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08); /* Мягче тень */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
overflow: hidden;
display: flex;
flex-direction: column; /* Для выравнивания кнопки внизу */
}
body.dark-mode .product {
background: #4a3f4d; /* Темный фон карточки */
color: #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.product:hover {
transform: translateY(-5px) scale(1.02);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
}
body.dark-mode .product:hover { box-shadow: 0 6px 20px rgba(200, 100, 200, 0.2); }
.product-image {
width: 100%;
aspect-ratio: 1;
background-color: #fff; /* Белый фон для чистоты фото */
border-radius: 10px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px; /* Отступ под фото */
}
.product-image img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.3s ease;
}
.product-image img:hover {
transform: scale(1.08); /* Чуть больше увеличение */
}
.product h2 {
font-size: 1rem;
font-weight: 600;
margin: 5px 0; /* Меньше вертикальный отступ */
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-grow: 0; /* Не растягивать */
}
.product-price {
font-size: 1.1rem;
color: #c864c8; /* Акцентный цвет для цены */
font-weight: 700;
text-align: center;
margin: 5px 0;
}
body.dark-mode .product-price { color: #f0a0f0; } /* Светлее в темной теме */
.product-description {
font-size: 0.8rem;
color: #7f7083; /* Приглушенный цвет */
text-align: center;
margin-bottom: 15px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2; /* Ограничение двумя строками */
-webkit-box-orient: vertical;
min-height: 2.4em; /* Гарантированная высота для 2 строк */
flex-grow: 1; /* Занять доступное место */
}
body.dark-mode .product-description {
color: #b2a8b0;
}
.product-button {
display: block;
width: 100%;
padding: 10px; /* Чуть больше кнопки */
border: none;
border-radius: 8px;
background-color: #c864c8; /* Акцентный */
color: white;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin: 5px 0;
text-align: center;
text-decoration: none;
margin-top: auto; /* Прижать кнопки к низу */
}
.product-button:hover {
background-color: #a84da8; /* Темнее при наведении */
box-shadow: 0 4px 15px rgba(168, 77, 168, 0.4);
transform: translateY(-2px);
}
.add-to-cart {
background-color: #e080e0; /* Светлее для корзины */
}
body.dark-mode .add-to-cart { background-color: #d878d8;}
.add-to-cart:hover {
background-color: #c864c8; /* Темнее при наведении */
box-shadow: 0 4px 15px rgba(200, 100, 200, 0.4);
}
#cart-button {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #c864c8; /* Акцентный */
color: white;
border: none;
border-radius: 50%;
width: 55px; /* Чуть больше */
height: 55px;
font-size: 1.5rem; /* Иконка больше */
cursor: pointer;
display: none; /* Скрыта по умолчанию */
align-items: center;
justify-content: center;
box-shadow: 0 4px 15px rgba(200, 100, 200, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
}
#cart-button:hover { transform: scale(1.1); background-color: #a84da8;}
.modal {
display: none;
position: fixed;
z-index: 1001;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(40,30,40,0.6); /* Полупрозрачный фон */
backdrop-filter: blur(5px);
overflow-y: auto; /* Разрешить прокрутку модального окна */
}
.modal-content {
background: #fff;
margin: 5% auto;
padding: 25px; /* Больше паддинг */
border-radius: 15px;
width: 90%;
max-width: 700px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
animation: slideIn 0.3s ease-out;
}
body.dark-mode .modal-content {
background: #4a3f4d; /* Темный фон модалки */
color: #e2d8e0;
}
@keyframes slideIn {
from { transform: translateY(-50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.close {
float: right;
font-size: 1.8rem; /* Крупнее крестик */
color: #af9faf; /* Серый */
cursor: pointer;
transition: color 0.3s;
line-height: 1;
}
.close:hover {
color: #c864c8; /* Акцентный при наведении */
}
body.dark-mode .close { color: #b2a8b0; }
body.dark-mode .close:hover { color: #f0a0f0; }
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
border-bottom: 1px solid #e8e0e8;
}
body.dark-mode .cart-item {
border-bottom: 1px solid #6a5f6d;
}
.cart-item img {
width: 60px; /* Крупнее фото в корзине */
height: 60px;
object-fit: contain;
border-radius: 8px;
margin-right: 15px;
background-color: #fff; /* Белый фон под фото */
}
.cart-item-details { flex-grow: 1; }
.cart-item-details strong { display: block; margin-bottom: 5px; }
.cart-item-details p { font-size: 0.9em; color: #7f7083; }
body.dark-mode .cart-item-details p { color: #b2a8b0; }
.cart-item-price { font-weight: bold; color: #c864c8; }
body.dark-mode .cart-item-price { color: #f0a0f0; }
.quantity-input, .color-select {
width: 100%;
max-width: 150px;
padding: 10px;
border: 1px solid #e8e0e8;
border-radius: 8px;
font-size: 1rem;
margin: 10px 0; /* Отступы для инпутов в модалке */
}
body.dark-mode .quantity-input, body.dark-mode .color-select {
background-color: #6a5f6d; border-color: #7f7083; color: #e2d8e0;
}
.cart-actions { margin-top: 20px; text-align: right; display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap;}
.clear-cart, .order-button { width: auto; padding: 10px 20px;} /* Автоширина для кнопок корзины */
.clear-cart {
background-color: #ef4444; /* Красный для очистки */
}
.clear-cart:hover {
background-color: #dc2626;
box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4);
}
.order-button {
background-color: #10b981; /* Зеленый для заказа */
}
.order-button:hover {
background-color: #059669;
box-shadow: 0 4px 15px rgba(5, 150, 105, 0.4);
}
.store-address {
padding: 15px;
text-align: center;
font-style: italic;
color: #7f7083;
background-color: rgba(232, 224, 232, 0.5); /* Легкий фон для адреса */
border-radius: 8px;
margin-bottom: 20px;
}
body.dark-mode .store-address {
color: #b2a8b0;
background-color: rgba(74, 63, 77, 0.5);
}
/* Убираем стрелки у input type=number */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] { -moz-appearance: textfield; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Soola Cosmetics</h1>
<div class="auth-links">
{% if is_authenticated %}
<span>Привет, {{ session['user'] }}!</span>
<a href="{{ url_for('logout') }}">Выйти</a>
{% else %}
<a href="{{ url_for('login') }}">Войти</a>
<!-- Ссылка на регистрацию убрана -->
{% endif %}
</div>
<button class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-moon"></i>
</button>
</div>
<div class="store-address">{{ store_address }}</div>
<div class="filters-container">
<button class="category-filter active" data-category="all">Все категории</button>
{% for category in categories %}
<button class="category-filter" data-category="{{ category }}">{{ category }}</button>
{% endfor %}
</div>
<div class="search-container">
<input type="text" id="search-input" placeholder="Поиск товаров...">
</div>
<div class="products-grid" id="products-grid">
{% if products %}
{% for product in products %}
<div class="product"
data-name="{{ product['name']|lower }}"
data-description="{{ product['description']|lower }}"
data-category="{{ product.get('category', 'Без категории') }}">
{% if product.get('photos') and product['photos']|length > 0 %}
<div class="product-image">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ product['photos'][0] }}"
alt="{{ product['name'] }}"
loading="lazy">
</div>
{% else %}
<div class="product-image">
<img src="https://via.placeholder.com/200x200?text=No+Image" alt="No Image Available">
</div>
{% endif %}
<h2>{{ product['name'] }}</h2>
{% if is_authenticated %}
<div class="product-price">{{ "%.2f"|format(product['price']) }} {{ currency_symbol }}</div>
{% else %}
<div class="product-price">Цена по запросу</div>
{% endif %}
<p class="product-description">{{ product['description'][:100] }}{% if product['description']|length > 100 %}...{% endif %}</p>
<button class="product-button" onclick="openModal({{ loop.index0 }})">Подробнее</button>
{% if is_authenticated %}
<button class="product-button add-to-cart" onclick="openQuantityModal({{ loop.index0 }})">В корзину</button>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="grid-column: 1 / -1; text-align: center;">Товары пока не добавлены.</p>
{% endif %}
</div>
</div>
<!-- Product Modal -->
<div id="productModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('productModal')">×</span>
<div id="modalContent"></div>
</div>
</div>
<!-- Quantity and Color Modal -->
<div id="quantityModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('quantityModal')">×</span>
<h2>Укажите количество и цвет/вариант</h2>
<label for="quantityInput">Количество:</label>
<input type="number" id="quantityInput" class="quantity-input" min="1" value="1">
<label for="colorSelect">Цвет/Вариант:</label>
<select id="colorSelect" class="color-select"></select>
<button class="product-button" onclick="confirmAddToCart()">Добавить в корзину</button>
</div>
</div>
<!-- Cart Modal -->
<div id="cartModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('cartModal')">×</span>
<h2>Корзина</h2>
<div id="cartContent"></div>
<div style="margin-top: 20px; text-align: right;">
<strong>Итого: <span id="cartTotal">0.00</span> {{ currency_symbol }}</strong>
</div>
<div class="cart-actions">
<button class="product-button clear-cart" onclick="clearCart()">Очистить корзину</button>
<button class="product-button order-button" onclick="orderViaWhatsApp()">Заказать по WhatsApp</button>
</div>
</div>
</div>
<button id="cart-button" onclick="openCartModal()">
<i class="fas fa-shopping-cart"></i>
</button>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <!-- Обновленный jQuery -->
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script> <!-- Обновленный Popper -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Swiper/10.2.0/swiper-bundle.min.js"></script>
<script>
// Убедимся, что products - это массив, даже если он пуст
const productsData = {{ products|tojson|safe if products else '[]' }};
const products = Array.isArray(productsData) ? productsData : [];
let selectedProductIndex = null;
const currencySymbol = '{{ currency_symbol }}';
const repoId = '{{ repo_id }}'; // Передаем repo_id в JS
function toggleTheme() {
document.body.classList.toggle('dark-mode');
const icon = document.querySelector('.theme-toggle i');
const isDarkMode = document.body.classList.contains('dark-mode');
if (isDarkMode) {
icon.classList.replace('fa-moon', 'fa-sun');
localStorage.setItem('theme', 'dark');
} else {
icon.classList.replace('fa-sun', 'fa-moon');
localStorage.setItem('theme', 'light');
}
}
// Применить тему при загрузке
if (localStorage.getItem('theme') === 'dark') {
document.body.classList.add('dark-mode');
const icon = document.querySelector('.theme-toggle i');
if(icon) icon.classList.replace('fa-moon', 'fa-sun');
}
// Автоматическая авторизация из localStorage (оставлено)
const storedUser = localStorage.getItem('user');
if (storedUser && !{{ is_authenticated|tojson }}) {
console.log("Попытка авто-логина для:", storedUser);
fetch('/auto_login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: storedUser })
}).then(response => {
if (response.ok) {
console.log("Авто-логин успешен, перезагрузка...");
window.location.reload();
} else {
console.log("Авто-логин не удался, удаление из localStorage");
localStorage.removeItem('user'); // Очистить, если автологин не сработал
}
}).catch(err => {
console.error("Ошибка при авто-логине:", err);
localStorage.removeItem('user');
});
}
function openModal(index) {
if(index >= 0 && index < products.length) {
loadProductDetails(index);
document.getElementById('productModal').style.display = "block";
document.body.style.overflow = 'hidden'; // Блокируем скролл фона
} else {
console.error("Неверный индекс продукта:", index);
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if(modal) modal.style.display = "none";
// Разблокировать скролл только если все модалки закрыты
const anyModalOpen = !!document.querySelector('.modal[style*="display: block"]');
if (!anyModalOpen) {
document.body.style.overflow = 'auto';
}
}
function loadProductDetails(index) {
fetch('/product/' + index)
.then(response => {
if (!response.ok) throw new Error('Network response was not ok');
return response.text();
})
.then(html => {
document.getElementById('modalContent').innerHTML = html;
initializeSwiper(); // Инициализировать Swiper после загрузки контента
})
.catch(error => {
console.error('Ошибка при загрузке деталей продукта:', error);
document.getElementById('modalContent').innerHTML = '<p>Не удалось загрузить информацию о товаре.</p>';
});
}
function initializeSwiper() {
// Убедимся, что контейнер существует перед инициализацией
const swiperContainer = document.querySelector('#productModal .swiper-container');
if (swiperContainer && typeof Swiper !== 'undefined') {
new Swiper(swiperContainer, {
slidesPerView: 1,
spaceBetween: 20,
loop: true,
grabCursor: true,
pagination: { el: '.swiper-pagination', clickable: true },
navigation: { nextEl: '.swiper-button-next', prevEl: '.swiper-button-prev' },
zoom: { maxRatio: 3, containerClass: 'swiper-zoom-container' }, // Убедитесь, что zoom включен и класс контейнера указан
});
} else {
console.log("Swiper container not found or Swiper library not loaded.");
}
}
function openQuantityModal(index) {
if(index < 0 || index >= products.length) {
console.error("Неверный индекс для модального окна количества:", index);
return;
}
selectedProductIndex = index;
const product = products[index];
const colorSelect = document.getElementById('colorSelect');
if (!colorSelect) {
console.error("Элемент colorSelect не найден");
return;
}
colorSelect.innerHTML = ''; // Очищаем перед заполнением
const colors = product.colors && Array.isArray(product.colors) && product.colors.length > 0
? product.colors
: ['Стандартный']; // Если цветов нет, добавляем опцию по умолчанию
colors.forEach(color => {
const option = document.createElement('option');
option.value = color;
option.text = color;
colorSelect.appendChild(option);
});
const quantityModal = document.getElementById('quantityModal');
const quantityInput = document.getElementById('quantityInput');
if(quantityModal && quantityInput) {
quantityInput.value = 1; // Сброс количества
quantityModal.style.display = 'block';
document.body.style.overflow = 'hidden'; // Блокируем скролл фона
} else {
console.error("Модальное окно quantityModal или поле quantityInput не найдены")
}
}
function confirmAddToCart() {
if (selectedProductIndex === null || selectedProductIndex < 0 || selectedProductIndex >= products.length) {
console.error("Не выбран продукт для добавления в корзину.");
return;
}
const quantityInput = document.getElementById('quantityInput');
const colorSelect = document.getElementById('colorSelect');
if (!quantityInput || !colorSelect) {
console.error("Не найдены элементы quantityInput или colorSelect.");
return;
}
const quantity = parseInt(quantityInput.value) || 0;
const color = colorSelect.value;
if (quantity <= 0) {
alert("Укажите количество больше 0");
return;
}
let cart = JSON.parse(localStorage.getItem('cart') || '[]');
const product = products[selectedProductIndex];
// Используем комбинацию ID (если есть) или имени и цвета как уникальный ключ
const cartItemId = `${product.id || product.name}-${color}`;
const existingItemIndex = cart.findIndex(item => item.id === cartItemId);
if (existingItemIndex > -1) {
// Обновляем количество существующего товара
cart[existingItemIndex].quantity += quantity;
} else {
// Добавляем новый товар
cart.push({
id: cartItemId, // Уникальный идентификатор в корзине
productId: product.id || null, // ID оригинального продукта, если есть
name: product.name,
price: product.price, // Цена всегда в сомах
photo: product.photos && product.photos.length > 0 ? product.photos[0] : '',
quantity: quantity,
color: color
});
}
localStorage.setItem('cart', JSON.stringify(cart));
closeModal('quantityModal');
updateCartButton(); // Обновляем вид кнопки корзины
console.log("Товар добавлен в корзину:", cart);
}
function updateCartButton() {
const cartButton = document.getElementById('cart-button');
if (!cartButton) return;
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
// Показываем кнопку, если корзина не пуста
cartButton.style.display = cart.length > 0 ? 'flex' : 'none'; // Используем flex для центрирования иконки
}
function openCartModal() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
const cartContent = document.getElementById('cartContent');
const cartTotalEl = document.getElementById('cartTotal');
if (!cartContent || !cartTotalEl) {
console.error("Элементы корзины cartContent или cartTotal не найдены.");
return;
}
let total = 0;
if (cart.length === 0) {
cartContent.innerHTML = '<p>Ваша корзина пуста.</p>';
} else {
cartContent.innerHTML = cart.map(item => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
const photoUrl = item.photo
? `https://huggingface.co/datasets/${repoId}/resolve/main/photos/${item.photo}`
: 'https://via.placeholder.com/60x60?text=N/A';
return `
<div class="cart-item">
<img src="${photoUrl}" alt="${item.name}">
<div class="cart-item-details">
<strong>${item.name}</strong>
<p>${item.price.toFixed(2)} ${currencySymbol} × ${item.quantity} (Вариант: ${item.color})</p>
</div>
<span class="cart-item-price">${itemTotal.toFixed(2)} ${currencySymbol}</span>
</div>
`;
}).join('');
}
cartTotalEl.textContent = total.toFixed(2);
const cartModal = document.getElementById('cartModal');
if (cartModal) {
cartModal.style.display = 'block';
document.body.style.overflow = 'hidden'; // Блокируем скролл фона
}
}
function orderViaWhatsApp() {
const cart = JSON.parse(localStorage.getItem('cart') || '[]');
if (cart.length === 0) {
alert("Ваша корзина пуста!");
return;
}
let total = 0;
let orderText = "Здравствуйте! Хочу сделать заказ:\n"; // Используем \n для переноса строки в WhatsApp
cart.forEach((item, index) => {
const itemTotal = item.price * item.quantity;
total += itemTotal;
// Форматируем строку товара
orderText += `${index + 1}. ${item.name} (${item.color}) - ${item.quantity} шт. x ${item.price.toFixed(2)} ${currencySymbol} = ${itemTotal.toFixed(2)} ${currencySymbol}\n`;
});
orderText += `\nИтого: ${total.toFixed(2)} ${currencySymbol}\n`;
// Добавляем информацию о пользователе, если он залогинен
const userLogin = '{{ session.get("user", "Не указан") }}';
if (userLogin !== "Не указан") {
orderText += `\nКлиент: ${userLogin}`;
}
// Кодируем текст для URL
const whatsappUrl = `https://api.whatsapp.com/send?phone=996555360556&text=${encodeURIComponent(orderText)}`;
// Открываем WhatsApp в новой вкладке
window.open(whatsappUrl, '_blank');
}
function clearCart() {
if (confirm("Вы уверены, что хотите очистить корзину?")) {
localStorage.removeItem('cart');
closeModal('cartModal');
updateCartButton(); // Обновляем кнопку (она должна скрыться)
}
}
// Закрытие модального окна по клику вне его
window.onclick = function(event) {
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
if (event.target == modal) {
closeModal(modal.id);
}
});
}
// Фильтрация и поиск
const searchInput = document.getElementById('search-input');
const categoryFilters = document.querySelectorAll('.category-filter');
const productsGrid = document.getElementById('products-grid');
const allProductElements = productsGrid ? Array.from(productsGrid.querySelectorAll('.product')) : [];
function filterProducts() {
if (!searchInput || !productsGrid) return;
const searchTerm = searchInput.value.toLowerCase().trim();
const activeCategoryFilter = document.querySelector('.category-filter.active');
const activeCategory = activeCategoryFilter ? activeCategoryFilter.dataset.category : 'all';
allProductElements.forEach(productElement => {
const name = productElement.getAttribute('data-name') || '';
const description = productElement.getAttribute('data-description') || '';
const category = productElement.getAttribute('data-category') || 'Без категории';
const matchesSearch = searchTerm === '' || name.includes(searchTerm) || description.includes(searchTerm);
const matchesCategory = activeCategory === 'all' || category === activeCategory;
// Показываем или скрываем товар
productElement.style.display = matchesSearch && matchesCategory ? 'flex' : 'none'; // Используем flex т.к. у .product display: flex
});
}
if(searchInput) {
searchInput.addEventListener('input', filterProducts);
}
categoryFilters.forEach(filter => {
filter.addEventListener('click', function() {
categoryFilters.forEach(f => f.classList.remove('active'));
this.classList.add('active');
filterProducts(); // Применяем фильтры
});
});
// Инициализация при загрузке
document.addEventListener('DOMContentLoaded', () => {
updateCartButton(); // Показать/скрыть кнопку корзины при загрузке
filterProducts(); // Применить фильтры по умолчанию (показать все)
});
</script>
</body>
</html>
'''
return render_template_string(
catalog_html,
products=products,
categories=categories,
repo_id=REPO_ID,
is_authenticated=is_authenticated,
store_address=STORE_ADDRESS,
session=session,
currency_symbol=CURRENCY_SYMBOL # Передаем символ валюты
)
@app.route('/product/<int:index>')
def product_detail(index):
data = load_data()
products = data.get('products', [])
is_authenticated = 'user' in session
if index < 0 or index >= len(products):
return "Товар не найден", 404
product = products[index]
detail_html = '''
<div class="container" style="padding: 20px;">
<h2 style="font-size: 1.8rem; font-weight: 600; margin-bottom: 20px; color: #8a4d80;">{{ product['name'] }}</h2>
<!-- Swiper -->
<div class="swiper-container" style="max-width: 400px; margin: 0 auto 20px; border-radius: 10px; overflow: hidden; background-color: #fff;">
<div class="swiper-wrapper">
{% if product.get('photos') and product['photos']|length > 0 %}
{% for photo in product['photos'] %}
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center;">
<!-- Контейнер для зума -->
<div class="swiper-zoom-container">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
alt="{{ product['name'] }} - фото {{ loop.index }}"
style="max-width: 100%; max-height: 400px; object-fit: contain; display: block;">
</div>
</div>
{% endfor %}
{% else %}
<div class="swiper-slide" style="display: flex; justify-content: center; align-items: center; height: 300px; background-color: #f0f0f0;">
<span style="color: #888;">Нет изображения</span>
</div>
{% endif %}
</div>
<!-- Add Pagination -->
<div class="swiper-pagination" style="position: relative; bottom: 5px;"></div>
<!-- Add Navigation -->
<div class="swiper-button-next" style="color: #c864c8;"></div>
<div class="swiper-button-prev" style="color: #c864c8;"></div>
</div>
<!-- End Swiper -->
<div style="text-align: left; margin-top: 20px; font-size: 1rem; line-height: 1.7;">
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
{% if is_authenticated %}
<p style="font-size: 1.2em; font-weight: bold; color: #c864c8;"><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_symbol }}</p>
{% else %}
<p><strong>Цена:</strong> Доступна после входа</p>
{% endif %}
<p><strong>Описание:</strong><br>{{ product['description'] | replace('\\n', '<br>') | safe }}</p>
{% if product.get('colors') and product['colors']|length > 0 %}
<p><strong>Доступные варианты/цвета:</strong> {{ product['colors']|join(', ') }}</p>
{% endif %}
</div>
</div>
'''
return render_template_string(
detail_html,
product=product,
repo_id=REPO_ID,
is_authenticated=is_authenticated,
currency_symbol=CURRENCY_SYMBOL # Передаем символ валюты
)
# Маршрут /set_currency убран, так как валюта теперь одна
# Маршрут /register убран, регистрация теперь только через админку
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
login = request.form.get('login')
password = request.form.get('password')
users = load_users()
if login in users and users[login].get('password') == password: # Проверяем наличие ключа 'password'
session['user'] = login
# Дополнительные данные пользователя в сессию больше не кладем (country, city)
# Валюта теперь не нужна в сессии
logging.info(f"Пользователь {login} успешно вошел в систему.")
# Сохраняем логин в localStorage для авто-логина
login_response_html = '''
<script>
localStorage.setItem('user', '{{ login }}');
window.location.href = "{{ url_for('catalog') }}";
</script>
<p>Вход выполнен успешно. Перенаправление...</p>
'''
return render_template_string(login_response_html, login=login)
# return redirect(url_for('catalog')) # Старый вариант без localStorage
else:
logging.warning(f"Неудачная попытка входа для пользователя: {login}")
error_message = "Неверный логин или пароль."
# Возвращаем страницу входа с сообщением об ошибке
return render_template_string(get_login_form_html(), error=error_message)
# GET запрос - просто показываем форму входа
return render_template_string(get_login_form_html())
def get_login_form_html():
"""Возвращает HTML-код формы входа."""
return '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход - Soola Cosmetics</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #f8f0f5, #f0e9ef);
padding: 40px 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
max-width: 400px;
width: 100%;
background: #fff;
padding: 30px;
border-radius: 15px;
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
}
h2 {
text-align: center;
margin-bottom: 25px;
color: #8a4d80;
font-weight: 600;
}
label {
display: block;
margin: 10px 0 5px;
color: #4a3f4d;
font-weight: 500;
}
input {
width: 100%;
padding: 12px;
margin-bottom: 15px;
border: 1px solid #e8e0e8;
border-radius: 8px;
font-size: 1rem;
box-sizing: border-box; /* Учитывать padding и border в ширине */
}
input:focus {
border-color: #c864c8;
outline: none;
box-shadow: 0 0 5px rgba(200, 100, 200, 0.3);
}
button {
width: 100%;
padding: 12px;
background-color: #c864c8;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: background-color 0.3s ease, transform 0.1s ease;
}
button:hover {
background-color: #a84da8;
}
button:active {
transform: scale(0.98); /* Небольшое сжатие при нажатии */
}
.error-message {
color: #dc2626;
background-color: #fef2f2;
border: 1px solid #fecaca;
padding: 10px;
border-radius: 8px;
margin-bottom: 15px;
text-align: center;
font-size: 0.9em;
}
.back-link {
display: block;
text-align: center;
margin-top: 20px;
color: #c864c8;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h2>Вход в Soola Cosmetics</h2>
{% if error %}
<div class="error-message">{{ error }}</div>
{% endif %}
<form method="POST">
<label for="login">Логин:</label>
<input type="text" id="login" name="login" required>
<label for="password">Пароль:</label>
<input type="password" id="password" name="password" required>
<button type="submit">Войти</button>
</form>
<a href="{{ url_for('catalog') }}" class="back-link">Вернуться в каталог</a>
</div>
<!-- Скрипт для localStorage убран отсюда, т.к. он теперь в ответе POST запроса -->
</body>
</html>
'''
@app.route('/auto_login', methods=['POST'])
def auto_login():
"""Попытка автоматического входа на основе данных из localStorage."""
data = request.get_json()
login = data.get('login')
if not login:
return "Логин не предоставлен", 400
users = load_users()
if login in users:
# Пароль для авто-логина не проверяем (риск безопасности, но удобно)
# Если нужна проверка, нужно будет передавать и сохраненный хеш/токен пароля
session['user'] = login
logging.info(f"Пользователь {login} автоматически вошел в систему.")
return "OK", 200
else:
logging.warning(f"Авто-логин не удался: пользователь {login} не найден.")
# Важно вернуть ошибку, чтобы клиентский JS удалил невалидный 'user' из localStorage
return "Пользователь не найден", 404
@app.route('/logout')
def logout():
user = session.get('user')
session.pop('user', None)
# Очистка других данных сессии, если они были
session.pop('country', None) # На всякий случай
session.pop('city', None) # На всякий случай
session.pop('currency', None) # На всякий случай
logging.info(f"Пользователь {user or 'Неизвестный'} вышел из системы.")
# Очищаем localStorage при выходе
logout_response_html = '''
<script>
localStorage.removeItem('user');
window.location.href = "{{ url_for('catalog') }}";
</script>
<p>Выход выполнен. Перенаправление...</p>
'''
return render_template_string(logout_response_html)
# return redirect(url_for('catalog')) # Старый вариант
@app.route('/admin', methods=['GET', 'POST'])
def admin():
# Простая проверка "админа" - первый зарегистрированный пользователь?
# Или лучше задать конкретный логин администратора
ADMIN_USER = os.getenv("ADMIN_LOGIN", "admin") # Можно задать через переменную окружения
if 'user' not in session or session['user'] != ADMIN_USER:
# Если не админ, перенаправляем на страницу входа или главную
logging.warning(f"Попытка неавторизованного доступа к /admin пользователем {session.get('user')}")
return redirect(url_for('login')) # Или url_for('catalog')
data = load_data()
products = data.get('products', [])
categories = data.get('categories', [])
users = load_users()
# Удаляем админа из списка пользователей для отображения (чтобы нельзя было случайно удалить)
users_display = {login: udata for login, udata in users.items() if login != ADMIN_USER}
if request.method == 'POST':
action = request.form.get('action')
logging.debug(f"Admin POST action: {action}")
try: # Обернем обработку POST в try-except для отлова ошибок
if action == 'add_category':
category_name = request.form.get('category_name', '').strip()
if category_name and category_name not in categories:
categories.append(category_name)
categories.sort() # Сортируем категории
save_data(data)
logging.info(f"Категория '{category_name}' добавлена администратором {session['user']}.")
elif not category_name:
logging.warning("Попытка добавить пустую категорию.")
else:
logging.warning(f"Попытка добавить существующую категорию: {category_name}")
return redirect(url_for('admin'))
elif action == 'delete_category':
category_index_str = request.form.get('category_index')
if category_index_str is not None:
try:
category_index = int(category_index_str)
if 0 <= category_index < len(categories):
deleted_category = categories.pop(category_index)
logging.info(f"Категория '{deleted_category}' удалена администратором {session['user']}.")
# Обновляем товары, у которых была эта категория
for product in products:
if product.get('category') == deleted_category:
product['category'] = 'Без категории'
save_data(data)
else:
logging.warning(f"Неверный индекс категории для удаления: {category_index}")
except ValueError:
logging.warning(f"Неверный формат индекса категории: {category_index_str}")
return redirect(url_for('admin'))
elif action == 'add':
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
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()]
if not name or not description: # Цена может быть 0
logging.warning("Попытка добавить товар без имени или описания.")
# Нужно вернуть ошибку пользователю
return "Ошибка: Название и Описание обязательны.", 400
try:
price = round(float(price_str), 2)
if price < 0: price = 0.0 # Цена не может быть отрицательной
except ValueError:
logging.warning(f"Неверный формат цены: {price_str}. Установлена цена 0.")
price = 0.0
photos_list = []
if photos_files and HF_TOKEN_WRITE: # Загрузка фото только если есть токен
uploads_dir = 'uploads_temp' # Временная папка
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in photos_files[:10]: # Ограничение на 10 фото
if photo and photo.filename:
try:
# Используем безопасное имя + таймстемп для уникальности
base, ext = os.path.splitext(photo.filename)
safe_base = secure_filename(base)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
photo_filename = f"{safe_base}_{timestamp}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
logging.debug(f"Фото сохранено временно: {temp_path}")
# Загружаем на HF
path_in_repo = f"photos/{photo_filename}"
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=path_in_repo,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Добавлено фото {photo_filename} для товара {name}"
)
photos_list.append(photo_filename)
logging.info(f"Фото {photo_filename} загружено на HF: {path_in_repo}")
# Удаляем временный файл
if os.path.exists(temp_path):
os.remove(temp_path)
logging.debug(f"Временное фото удалено: {temp_path}")
except Exception as e:
logging.error(f"Ошибка при обработке или загрузке фото {photo.filename}: {e}")
# Удаляем временный файл, если он остался после ошибки
if 'temp_path' in locals() and os.path.exists(temp_path):
try: os.remove(temp_path)
except Exception as rem_e: logging.error(f"Ошибка при удалении временного файла после ошибки: {rem_e}")
# Очищаем временную папку, если она пуста
if not os.listdir(uploads_dir):
os.rmdir(uploads_dir)
elif photos_files and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN для записи не установлен, фото не будут загружены.")
new_product = {
'id': f"prod_{int(time.time() * 1000)}", # Простой уникальный ID на основе времени
'name': name,
'price': price, # Цена в сомах
'description': description,
'category': category if category in categories else 'Без категории',
'photos': photos_list,
'colors': colors if colors else [] # Пустой список, если цвета не указаны
}
products.append(new_product)
save_data(data)
logging.info(f"Товар '{name}' добавлен администратором {session['user']}.")
return redirect(url_for('admin'))
elif action == 'edit':
index_str = request.form.get('index')
if index_str is None: return redirect(url_for('admin'))
try:
index = int(index_str)
if not (0 <= index < len(products)):
logging.warning(f"Попытка редактировать несуществующий товар с индексом {index}.")
return redirect(url_for('admin'))
name = request.form.get('name', '').strip()
price_str = request.form.get('price', '0').replace(',', '.')
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()]
if not name or not description:
logging.warning("Попытка сохранить товар без имени или описания при редактировании.")
return "Ошибка: Название и Описание обязательны.", 400
try:
price = round(float(price_str), 2)
if price < 0: price = 0.0
except ValueError:
logging.warning(f"Неверный формат цены при редактировании: {price_str}. Цена не изменена.")
price = products[index]['price'] # Оставляем старую цену
# Обработка фото: Если загружены новые фото, они *заменяют* старые
new_photos_list = products[index].get('photos', []) # Начинаем со старых фото
if photos_files and any(f.filename for f in photos_files) and HF_TOKEN_WRITE:
logging.info(f"Загрузка новых фото для товара '{name}' (индекс {index}). Старые будут заменены.")
# TODO: В идеале, нужно удалить старые фото с HF, но это сложнее.
# Пока просто заменяем список в JSON.
new_photos_list = [] # Очищаем список перед добавлением новых
uploads_dir = 'uploads_temp'
os.makedirs(uploads_dir, exist_ok=True)
api = HfApi()
for photo in photos_files[:10]:
if photo and photo.filename:
try:
base, ext = os.path.splitext(photo.filename)
safe_base = secure_filename(base)
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
photo_filename = f"{safe_base}_{timestamp}{ext}"
temp_path = os.path.join(uploads_dir, photo_filename)
photo.save(temp_path)
path_in_repo = f"photos/{photo_filename}"
api.upload_file(
path_or_fileobj=temp_path,
path_in_repo=path_in_repo,
repo_id=REPO_ID,
repo_type="dataset",
token=HF_TOKEN_WRITE,
commit_message=f"Обновлено фото {photo_filename} для товара {name}"
)
new_photos_list.append(photo_filename)
logging.info(f"Новое фото {photo_filename} загружено на HF для товара {name}.")
if os.path.exists(temp_path): os.remove(temp_path)
except Exception as e:
logging.error(f"Ошибка при обработке/загрузке нового фото {photo.filename}: {e}")
if 'temp_path' in locals() and os.path.exists(temp_path):
try: os.remove(temp_path)
except Exception as rem_e: logging.error(f"Ошибка при удалении временного файла после ошибки: {rem_e}")
if not os.listdir(uploads_dir): os.rmdir(uploads_dir)
elif photos_files and not HF_TOKEN_WRITE:
logging.warning("HF_TOKEN для записи не установлен, новые фото не будут загружены при редактировании.")
# new_photos_list остается равным старым фото
# Обновляем данные продукта
products[index]['name'] = name
products[index]['price'] = price
products[index]['description'] = description
products[index]['category'] = category if category in categories else 'Без категории'
products[index]['photos'] = new_photos_list # Обновляем список фото
products[index]['colors'] = colors if colors else [] # Обновляем цвета
save_data(data)
logging.info(f"Товар '{name}' (индекс {index}) обновлен администратором {session['user']}.")
except ValueError:
logging.warning(f"Неверный формат индекса товара для редактирования: {index_str}")
except Exception as e:
logging.error(f"Непредвиденная ошибка при редактировании товара: {e}")
return redirect(url_for('admin'))
elif action == 'delete':
index_str = request.form.get('index')
if index_str is not None:
try:
index = int(index_str)
if 0 <= index < len(products):
deleted_product = products.pop(index)
# TODO: В идеале, удалить и фото с HF
save_data(data)
logging.info(f"Товар '{deleted_product.get('name', 'N/A')}' (индекс {index}) удален администратором {session['user']}.")
else:
logging.warning(f"Попытка удалить несуществующий товар с индексом {index}.")
except ValueError:
logging.warning(f"Неверный формат индекса товара для удаления: {index_str}")
return redirect(url_for('admin'))
# --- Управление пользователями ---
elif action == 'add_user':
login = request.form.get('login', '').strip()
password = request.form.get('password', '').strip()
if login and password:
if login in users:
logging.warning(f"Попытка добавить существующего пользователя: {login}")
# Можно вернуть ошибку на страницу админа
else:
users[login] = {'password': password} # Храним пароль открытым текстом (НЕ РЕКОМЕНДУЕТСЯ!)
# В реальном приложении нужно использовать хеширование паролей (например, Werkzeug security)
save_users(users)
logging.info(f"Пользователь '{login}' добавлен администратором {session['user']}.")
else:
logging.warning("Попытка добавить пользователя с пустым логином или паролем.")
return redirect(url_for('admin'))
elif action == 'delete_user':
login_to_delete = request.form.get('login')
if login_to_delete and login_to_delete != ADMIN_USER: # Нельзя удалить админа
if login_to_delete in users:
del users[login_to_delete]
save_users(users)
logging.info(f"Пользователь '{login_to_delete}' удален администратором {session['user']}.")
else:
logging.warning(f"Попытка удалить несуществующего пользователя: {login_to_delete}")
elif login_to_delete == ADMIN_USER:
logging.warning(f"Попытка удалить администратора ({ADMIN_USER}) отклонена.")
return redirect(url_for('admin'))
except Exception as e:
logging.error(f"Критическая ошибка при обработке POST запроса в /admin: {e}", exc_info=True)
# Можно показать страницу с ошибкой или просто перенаправить
return redirect(url_for('admin')) # Перенаправляем в любом случае
# GET запрос - отображаем страницу админки
admin_html = '''
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель - Soola Cosmetics</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;600&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Poppins', sans-serif;
background: linear-gradient(135deg, #f0f2f5, #e9ecef);
color: #2d3748;
padding: 20px;
line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; }
.header { padding: 15px 0; border-bottom: 1px solid #e2e8f0; margin-bottom: 30px; display: flex; justify-content: space-between; align-items: center; }
h1, h2, h3 { font-weight: 600; margin-bottom: 15px; color: #4a5568; }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.5rem; margin-top: 30px; border-top: 1px solid #e2e8f0; padding-top: 20px;}
h3 { font-size: 1.2rem; margin-bottom: 10px; }
form {
background: #fff;
padding: 20px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
label { font-weight: 500; margin-top: 10px; display: block; color: #4a5568; }
input[type="text"], input[type="number"], input[type="password"], textarea, select, input[type="file"] {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 0.95rem;
transition: all 0.3s ease;
box-sizing: border-box;
}
input:focus, textarea:focus, select:focus {
border-color: #a84da8; /* Фиолетовый акцент */
box-shadow: 0 0 5px rgba(168, 77, 168, 0.3);
outline: none;
}
textarea { min-height: 80px; resize: vertical; }
button {
padding: 10px 18px;
border: none;
border-radius: 8px;
background-color: #c864c8; /* Фиолетовый */
color: white;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
margin-top: 15px;
font-size: 0.95rem;
}
button:hover {
background-color: #a84da8;
box-shadow: 0 4px 15px rgba(168, 77, 168, 0.3);
transform: translateY(-1px);
}
button:active { transform: translateY(0); }
.delete-button { background-color: #ef4444; }
.delete-button:hover { background-color: #dc2626; box-shadow: 0 4px 15px rgba(220, 38, 38, 0.4); }
.action-button { background-color: #3b82f6; } /* Синий для действий типа "Скачать" */
.action-button:hover { background-color: #2563eb; box-shadow: 0 4px 15px rgba(37, 99, 235, 0.4); }
.grid-container { display: grid; gap: 20px; }
.item-card { background: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); }
.item-card h3 { margin-top: 0; }
.edit-form { margin-top: 15px; padding: 15px; background: #f7fafc; border: 1px solid #e8e0e8; border-radius: 8px; }
.color-input-group { display: flex; gap: 10px; margin-bottom: 5px; align-items: center; }
.color-input-group input { flex-grow: 1; margin-top: 0;}
.remove-color-btn { background-color: #f59e0b; font-size: 0.8em; padding: 5px 8px; margin-top: 0; } /* Оранжевый для удаления цвета */
.remove-color-btn:hover { background-color: #d97706; }
.add-color-btn { background-color: #10b981; margin-top: 5px; } /* Зеленый для добавления */
.add-color-btn:hover { background-color: #059669; }
.product-photos img { max-width: 80px; height: auto; border-radius: 5px; margin: 5px 5px 0 0; border: 1px solid #eee;}
details summary { cursor: pointer; font-weight: 500; color: #a84da8; display: inline-block; padding: 5px 0; }
details[open] summary { margin-bottom: 10px; }
.sync-buttons form { display: inline-block; margin-right: 10px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Админ-панель Soola Cosmetics</h1>
<a href="{{ url_for('catalog') }}" style="color: #c864c8; text-decoration: none;">Перейти в каталог</a>
</div>
<!-- Блок синхронизации -->
<h2>Синхронизация с Hugging Face</h2>
<div class="sync-buttons item-card">
<p>Данные автоматически сохраняются на Hugging Face при изменениях и периодически.</p>
<form method="POST" action="{{ url_for('backup') }}">
<button type="submit" class="action-button">Принудительно сохранить сейчас</button>
</form>
<form method="GET" action="{{ url_for('download') }}">
<button type="submit" class="action-button">Скачать последнюю версию</button>
(Перезапишет локальные файлы!)
</form>
</div>
<!-- Управление категориями -->
<h2>Управление категориями</h2>
<div class="grid-container" style="grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));">
<form method="POST" class="item-card">
<h3>Добавить категорию</h3>
<input type="hidden" name="action" value="add_category">
<label for="category_name">Название новой категории:</label>
<input type="text" id="category_name" name="category_name" required>
<button type="submit">Добавить</button>
</form>
<div class="item-card">
<h3>Список категорий</h3>
{% if categories %}
<ul>
{% for category in categories %}
<li style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid #eee;">
<span>{{ category }}</span>
{% if category != 'Без категории' %}
<form method="POST" style="display: inline; margin-left: 10px;">
<input type="hidden" name="action" value="delete_category">
<input type="hidden" name="category_index" value="{{ loop.index0 }}">
<button type="submit" class="delete-button" style="font-size: 0.8em; padding: 4px 8px;" onclick="return confirm('Удалить категорию {{ category }}? Товары будут перемещены в Без категории.');">Удалить</button>
</form>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p>Категорий пока нет.</p>
{% endif %}
</div>
</div>
<!-- Добавление товара -->
<h2>Добавление товара</h2>
<form method="POST" enctype="multipart/form-data" class="item-card">
<input type="hidden" name="action" value="add">
<label for="add_name">Название товара:</label>
<input type="text" id="add_name" name="name" required>
<label for="add_price">Цена ({{ currency_symbol }}):</label>
<input type="number" id="add_price" name="price" step="0.01" min="0" required>
<label for="add_description">Описание:</label>
<textarea id="add_description" name="description" rows="4" required></textarea>
<label for="add_category">Категория:</label>
<select id="add_category" name="category">
<option value="Без категории">Без категории</option>
{% for category in categories %}
{% if category != 'Без категории' %} <option value="{{ category }}">{{ category }}</option> {% endif %}
{% endfor %}
</select>
<label for="add_photos">Фотографии (до 10, будут загружены на HF):</label>
<input type="file" id="add_photos" name="photos" accept="image/*" multiple>
<label>Цвета/Варианты (необязательно):</label>
<div id="color-inputs">
<!-- Динамически добавляемые поля -->
</div>
<button type="button" class="add-color-btn" onclick="addColorInput('color-inputs')">Добавить вариант</button>
<button type="submit" style="margin-top: 20px;">Добавить товар</button>
</form>
<!-- Список товаров -->
<h2>Список товаров</h2>
<div class="grid-container" style="grid-template-columns: 1fr;"> <!-- Один столбец для списка товаров -->
{% if products %}
{% for product in products %}
<div class="item-card product-item">
<h3>{{ product['name'] }}</h3>
<p><strong>ID:</strong> {{ product.get('id', 'N/A') }}</p>
<p><strong>Категория:</strong> {{ product.get('category', 'Без категории') }}</p>
<p><strong>Цена:</strong> {{ "%.2f"|format(product['price']) }} {{ currency_symbol }}</p>
<p><strong>Описание:</strong> {{ product['description'][:150] }}{% if product['description']|length > 150 %}...{% endif %}</p>
<p><strong>Варианты:</strong> {{ product.get('colors', ['Стандартный'])|join(', ') }}</p>
{% if product.get('photos') and product['photos']|length > 0 %}
<div class="product-photos">
<strong>Фото:</strong><br>
{% for photo in product['photos'] %}
<a href="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}" target="_blank">
<img src="https://huggingface.co/datasets/{{ repo_id }}/resolve/main/photos/{{ photo }}"
alt="Фото {{ loop.index }}">
</a>
{% endfor %}
</div>
{% else %}
<p><strong>Фото:</strong> Нет</p>
{% endif %}
<details style="margin-top: 15px;">
<summary>Редактировать</summary>
<form method="POST" enctype="multipart/form-data" class="edit-form">
<input type="hidden" name="action" value="edit">
<input type="hidden" name="index" value="{{ loop.index0 }}">
<label for="edit_name_{{ loop.index0 }}">Название:</label>
<input type="text" id="edit_name_{{ loop.index0 }}" name="name" value="{{ product['name'] }}" required>
<label for="edit_price_{{ loop.index0 }}">Цена ({{ currency_symbol }}):</label>
<input type="number" id="edit_price_{{ loop.index0 }}" name="price" step="0.01" min="0" value="{{ product['price'] }}" required>
<label for="edit_description_{{ loop.index0 }}">Описание:</label>
<textarea id="edit_description_{{ loop.index0 }}" name="description" rows="4" required>{{ product['description'] }}</textarea>
<label for="edit_category_{{ loop.index0 }}">Категория:</label>
<select id="edit_category_{{ loop.index0 }}" name="category">
<option value="Без категории" {% if product.get('category', 'Без категории') == 'Без категории' %}selected{% endif %}>Без категории</option>
{% for category in categories %}
{% if category != 'Без категории' %}
<option value="{{ category }}" {% if product.get('category') == category %}selected{% endif %}>{{ category }}</option>
{% endif %}
{% endfor %}
</select>
<label for="edit_photos_{{ loop.index0 }}">Заменить фотографии (до 10, старые будут заменены):</label>
<input type="file" id="edit_photos_{{ loop.index0 }}" name="photos" accept="image/*" multiple>
<label>Цвета/Варианты:</label>
<div id="edit-color-inputs-{{ loop.index0 }}">
{% if product.get('colors') %}
{% for color in product.get('colors', []) %}
<div class="color-input-group">
<input type="text" name="colors" value="{{ color }}">
<button type="button" class="remove-color-btn" onclick="this.parentElement.remove()">Удалить</button>
</div>
{% endfor %}
{% endif %}
<!-- Если цветов нет, при добавлении появится первое поле -->
</div>
<button type="button" class="add-color-btn" onclick="addColorInput('edit-color-inputs-{{ loop.index0 }}')">Добавить вариант</button>
<button type="submit" style="margin-top: 20px;">Сохранить изменения</button>
</form>
</details>
<form method="POST" style="display: inline-block; margin-left: 10px;">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="index" value="{{ loop.index0 }}">
<button type="submit" class="delete-button" onclick="return confirm('Вы уверены, что хотите удалить товар {{ product['name'] }}?');">Удалить товар</button>
</form>
</div>
{% endfor %}
{% else %}
<p>Товаров пока нет.</p>
{% endif %}
</div>
<!-- Управление пользователями -->
<h2>Управление пользователями</h2>
<div class="grid-container" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
<form method="POST" class="item-card">
<h3>Добавить пользователя</h3>
<input type="hidden" name="action" value="add_user">
<label for="add_login">Логин:</label>
<input type="text" id="add_login" name="login" required>
<label for="add_password">Пароль:</label>
<input type="password" id="add_password" name="password" required>
<button type="submit">Добавить пользователя</button>
</form>
<div class="item-card">
<h3>Список пользователей</h3>
<p>(Администратор '{{ admin_user }}' не отображается)</p>
{% if users_display %}
<ul>
{% for login, user_data in users_display.items() %}
<li style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px; padding-bottom: 5px; border-bottom: 1px solid #eee;">
<span>{{ login }}</span>
<form method="POST" style="display: inline; margin-left: 10px;">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="login" value="{{ login }}">
<button type="submit" class="delete-button" style="font-size: 0.8em; padding: 4px 8px;" onclick="return confirm('Удалить пользователя {{ login }}?');">Удалить</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p>Других пользователей нет.</p>
{% endif %}
</div>
</div>
</div>
<script>
function addColorInput(containerId) {
const container = document.getElementById(containerId);
if (!container) return;
const newInputGroup = document.createElement('div');
newInputGroup.className = 'color-input-group';
newInputGroup.innerHTML = `
<input type="text" name="colors" placeholder="Например: Розовый">
<button type="button" class="remove-color-btn" onclick="this.parentElement.remove()">Удалить</button>
`;
container.appendChild(newInputGroup);
}
</script>
</body>
</html>
'''
return render_template_string(
admin_html,
products=products,
categories=categories,
repo_id=REPO_ID,
users=users_display, # Отображаем пользователей без админа
admin_user=ADMIN_USER, # Передаем логин админа
currency_symbol=CURRENCY_SYMBOL
)
@app.route('/backup', methods=['POST'])
def backup():
"""Принудительная загрузка всех данных на Hugging Face."""
# Проверка прав доступа (на всякий случай)
ADMIN_USER = os.getenv("ADMIN_LOGIN", "admin")
if 'user' not in session or session['user'] != ADMIN_USER:
return "Доступ запрещен", 403
logging.info(f"Запрошено принудительное резервное копирование администратором {session['user']}.")
upload_db_to_hf() # Загружаем все файлы из SYNC_FILES
# Можно добавить сообщение об успехе/ошибке через flash или вернуть статус
return redirect(url_for('admin')) # Возвращаемся в админку
@app.route('/download', methods=['GET'])
def download():
"""Принудительное скачивание всех данных с Hugging Face."""
# Проверка прав доступа
ADMIN_USER = os.getenv("ADMIN_LOGIN", "admin")
if 'user' not in session or session['user'] != ADMIN_USER:
return "Доступ запрещен", 403
logging.info(f"Запрошено принудительное скачивание данных с HF администратором {session['user']}.")
try:
download_db_from_hf()
# После скачивания хорошо бы перезагрузить данные в приложении,
# но это произойдет при следующем запросе к load_data/load_users.
# Можно добавить сообщение об успехе
except Exception as e:
logging.error(f"Ошибка при принудительном скачивании с HF: {e}")
# Можно добавить сообщение об ошибке
return redirect(url_for('admin')) # Возвращаемся в админку
if __name__ == '__main__':
# Загружаем данные при старте для инициализации
try:
initial_data = load_data()
initial_users = load_users()
logging.info(f"Начальная загрузка: {len(initial_data.get('products',[]))} товаров, {len(initial_data.get('categories',[]))} категорий, {len(initial_users)} пользователей.")
except Exception as e:
logging.error(f"Не удалось загрузить данные при запуске приложения: {e}")
# Запускаем поток для периодического резервного копирования
# Убедимся, что токен для записи существует, иначе поток не нужен
if HF_TOKEN_WRITE:
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
logging.info("Поток периодического резервного копирования запущен.")
else:
logging.warning("Периодическое резервное копирование отключено (нет HF_TOKEN).")
# Запуск Flask приложения
# Используйте Gunicorn или Waitress для продакшена вместо встроенного сервера Flask
app.run(debug=False, host='0.0.0.0', port=7860) # debug=False для продакшена!